calliope-ts 0.0.1
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.
- package/.claude/settings.local.json +8 -0
- package/.opencode/skills/calliope-ts/SKILL.md +74 -0
- package/LICENSE +201 -0
- package/README.md +485 -0
- package/dist/depfix.d.ts +13 -0
- package/dist/depfix.d.ts.map +1 -0
- package/dist/depfix.js +84 -0
- package/dist/display.d.ts +38 -0
- package/dist/display.d.ts.map +1 -0
- package/dist/display.js +890 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +504 -0
- package/dist/parser.d.ts +10 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +688 -0
- package/dist/phonological.d.ts +41 -0
- package/dist/phonological.d.ts.map +1 -0
- package/dist/phonological.js +788 -0
- package/dist/rhyme.d.ts +26 -0
- package/dist/rhyme.d.ts.map +1 -0
- package/dist/rhyme.js +340 -0
- package/dist/scandroid.d.ts +17 -0
- package/dist/scandroid.d.ts.map +1 -0
- package/dist/scandroid.js +435 -0
- package/dist/scansion.d.ts +37 -0
- package/dist/scansion.d.ts.map +1 -0
- package/dist/scansion.js +1007 -0
- package/dist/scansion_debug.js +586 -0
- package/dist/stress.d.ts +32 -0
- package/dist/stress.d.ts.map +1 -0
- package/dist/stress.js +1372 -0
- package/dist/tagfix.d.ts +6 -0
- package/dist/tagfix.d.ts.map +1 -0
- package/dist/tagfix.js +101 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +62 -0
- package/src/depfix.ts +88 -0
- package/src/display.ts +954 -0
- package/src/index.ts +541 -0
- package/src/parser.ts +837 -0
- package/src/phonological.ts +849 -0
- package/src/rhyme.ts +328 -0
- package/src/scandroid.ts +434 -0
- package/src/scansion.ts +1053 -0
- package/src/stress.ts +1381 -0
- package/src/tagfix.ts +104 -0
- package/src/types.ts +230 -0
package/src/rhyme.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// rhyme.ts — Rhyme-pair classification, stanza rhyme-scheme detection, and
|
|
2
|
+
// poetic FORM identification.
|
|
3
|
+
//
|
|
4
|
+
// Rhyme typology follows the maintainer's LYRICAL app (meter_exemplars.ts
|
|
5
|
+
// RHYME_TYPES) so the two toolkits stay cross-compatible: perfect / rich /
|
|
6
|
+
// family / assonant / consonant / augmented / diminished / wrenched / eye /
|
|
7
|
+
// identical, with the structural qualifiers masculine / feminine / dactylic.
|
|
8
|
+
// Phonology comes from the augmented CMU dictionary (nounsing-pro); the
|
|
9
|
+
// orthographic (eye/wrenched) tier is a deliberately guarded fallback.
|
|
10
|
+
//
|
|
11
|
+
// FORM is a stanza/poem-level verdict (this is where "ballad" lives — a
|
|
12
|
+
// quatrain with ballad rhyme AND alternating 4·3 beats — NOT in the rhythm
|
|
13
|
+
// pass). Form names align with LYRICAL's FORM_REGISTRY where they overlap
|
|
14
|
+
// (Couplet, Triplet, Quatrain, Limerick, Petrarchan/Shakespearean Sonnet…).
|
|
15
|
+
|
|
16
|
+
import * as nounsing from 'nounsing-pro';
|
|
17
|
+
import { LineResult, PhonologicalScansionDetail } from './types.js';
|
|
18
|
+
import { isPunctuation } from './parser.js';
|
|
19
|
+
import { ictusProfile } from './scansion.js';
|
|
20
|
+
|
|
21
|
+
export type RhymeTypeName =
|
|
22
|
+
| 'identical' | 'rich' | 'perfect' | 'family'
|
|
23
|
+
| 'assonant' | 'consonant' | 'augmented' | 'diminished'
|
|
24
|
+
| 'wrenched' | 'eye';
|
|
25
|
+
|
|
26
|
+
export interface RhymePair {
|
|
27
|
+
type: RhymeTypeName;
|
|
28
|
+
/** masculine (stress on final syllable) / feminine (penult) / dactylic (antepenult) */
|
|
29
|
+
structure?: 'masculine' | 'feminine' | 'dactylic';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const VOWEL_RE = /^(AA|AE|AH|AO|AW|AY|EH|ER|EY|IH|IY|OW|OY|UH|UW)/;
|
|
33
|
+
const PLOSIVES = new Set(['B', 'D', 'G', 'P', 'T', 'K']);
|
|
34
|
+
const FRICATIVES = new Set(['V', 'DH', 'Z', 'ZH', 'JH', 'F', 'TH', 'S', 'SH', 'CH', 'HH']);
|
|
35
|
+
const NASALS = new Set(['M', 'N', 'NG']);
|
|
36
|
+
|
|
37
|
+
const isVowelPhone = (p: string) => VOWEL_RE.test(p);
|
|
38
|
+
const base = (p: string) => p.replace(/[0-9]/g, '');
|
|
39
|
+
const sameFamily = (a: string, b: string) =>
|
|
40
|
+
(PLOSIVES.has(a) && PLOSIVES.has(b)) || (FRICATIVES.has(a) && FRICATIVES.has(b)) || (NASALS.has(a) && NASALS.has(b));
|
|
41
|
+
|
|
42
|
+
function phonesOf(word: string): string[] | null {
|
|
43
|
+
const clean = word.toLowerCase().replace(/[^a-z']/g, '');
|
|
44
|
+
if (!clean) return null;
|
|
45
|
+
try {
|
|
46
|
+
const ph = nounsing.firstPhonesForWord(clean);
|
|
47
|
+
if (typeof ph === 'string' && ph.length > 0) return ph.split(' ');
|
|
48
|
+
} catch { /* OOV */ }
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Index of the LAST stressed (1/2) vowel; falls back to the last vowel. */
|
|
53
|
+
function lastStressedIdx(ph: string[]): number {
|
|
54
|
+
let lastVowel = -1;
|
|
55
|
+
for (let i = ph.length - 1; i >= 0; i--) {
|
|
56
|
+
if (!isVowelPhone(ph[i])) continue;
|
|
57
|
+
if (lastVowel < 0) lastVowel = i;
|
|
58
|
+
if (/[12]$/.test(ph[i])) return i;
|
|
59
|
+
}
|
|
60
|
+
return lastVowel;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** masculine/feminine/dactylic from how many vowels FOLLOW the rhyming vowel. */
|
|
64
|
+
function structureOf(ph: string[], idx: number): RhymePair['structure'] {
|
|
65
|
+
let after = 0;
|
|
66
|
+
for (let i = idx + 1; i < ph.length; i++) if (isVowelPhone(ph[i])) after++;
|
|
67
|
+
return after === 0 ? 'masculine' : after === 1 ? 'feminine' : 'dactylic';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Guarded orthographic tier: shared ending ≥3 chars, matching final phone if
|
|
71
|
+
* known, and never on a shared bare "-ing" (mass false positives). */
|
|
72
|
+
function orthographicTier(a: string, b: string, pa: string[] | null, pb: string[] | null): RhymeTypeName | null {
|
|
73
|
+
const wa = a.toLowerCase().replace(/[^a-z]/g, '');
|
|
74
|
+
const wb = b.toLowerCase().replace(/[^a-z]/g, '');
|
|
75
|
+
if (wa.length < 3 || wb.length < 3) return null;
|
|
76
|
+
let common = 0;
|
|
77
|
+
while (common < Math.min(wa.length, wb.length) && wa[wa.length - 1 - common] === wb[wb.length - 1 - common]) common++;
|
|
78
|
+
if (common < 3) return null;
|
|
79
|
+
if (wa.slice(-3) === 'ing' && wb.slice(-3) === 'ing' && common <= 4) return null;
|
|
80
|
+
if (pa && pb && base(pa[pa.length - 1]) !== base(pb[pb.length - 1])) return null;
|
|
81
|
+
// Wrenched when the shared ending is an UNSTRESSED suffix of a polysyllable
|
|
82
|
+
// (temperate/date, manifestation/convention); plain eye-rhyme otherwise.
|
|
83
|
+
const polyUnstressed = (ph: string[] | null) => {
|
|
84
|
+
if (!ph) return false;
|
|
85
|
+
const idx = lastStressedIdx(ph);
|
|
86
|
+
return idx >= 0 && structureOf(ph, idx) !== 'masculine';
|
|
87
|
+
};
|
|
88
|
+
return polyUnstressed(pa) !== polyUnstressed(pb) ? 'wrenched' : 'eye';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Classify the rhyme relation between two line-end words (or null). */
|
|
92
|
+
export function classifyRhymePair(a: string, b: string): RhymePair | null {
|
|
93
|
+
const wa = a.toLowerCase().replace(/[^a-z']/g, '');
|
|
94
|
+
const wb = b.toLowerCase().replace(/[^a-z']/g, '');
|
|
95
|
+
if (!wa || !wb) return null;
|
|
96
|
+
const pa = phonesOf(a);
|
|
97
|
+
const pb = phonesOf(b);
|
|
98
|
+
if (wa === wb) return { type: 'identical', structure: pa ? structureOf(pa, lastStressedIdx(pa)) : undefined };
|
|
99
|
+
if (!pa || !pb) {
|
|
100
|
+
const t = orthographicTier(a, b, pa, pb);
|
|
101
|
+
return t ? { type: t } : null;
|
|
102
|
+
}
|
|
103
|
+
const ia = lastStressedIdx(pa);
|
|
104
|
+
const ib = lastStressedIdx(pb);
|
|
105
|
+
if (ia < 0 || ib < 0) return null;
|
|
106
|
+
const ra = pa.slice(ia);
|
|
107
|
+
const rb = pb.slice(ib);
|
|
108
|
+
const structure = structureOf(pa, ia);
|
|
109
|
+
const sameStructure = structure === structureOf(pb, ib);
|
|
110
|
+
|
|
111
|
+
const norm = (seg: string[]) => seg.map(base);
|
|
112
|
+
const na = norm(ra);
|
|
113
|
+
const nb = norm(rb);
|
|
114
|
+
const partsEqual = na.length === nb.length && na.every((p, i) => p === nb[i]);
|
|
115
|
+
|
|
116
|
+
if (partsEqual && sameStructure) {
|
|
117
|
+
const onsetA = ia > 0 ? base(pa[ia - 1]) : '';
|
|
118
|
+
const onsetB = ib > 0 ? base(pb[ib - 1]) : '';
|
|
119
|
+
return { type: onsetA === onsetB ? 'rich' : 'perfect', structure };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const vowelSame = na[0] === nb[0];
|
|
123
|
+
const codaA = na.slice(1).filter(p => !isVowelPhone(p));
|
|
124
|
+
const codaB = nb.slice(1).filter(p => !isVowelPhone(p));
|
|
125
|
+
const codaEq = codaA.length === codaB.length && codaA.every((p, i) => p === codaB[i]);
|
|
126
|
+
|
|
127
|
+
if (vowelSame) {
|
|
128
|
+
// Same stressed vowel. Matching-length codas whose consonants pair up
|
|
129
|
+
// within one phonetic family (wet/deck, dame/grain) → family rhyme.
|
|
130
|
+
if (codaA.length === codaB.length && codaA.length > 0
|
|
131
|
+
&& codaA.every((p, i) => p === codaB[i] || sameFamily(p, codaB[i]))) {
|
|
132
|
+
return { type: 'family', structure };
|
|
133
|
+
}
|
|
134
|
+
// One extra trailing consonant on the second/first word (bray/brave).
|
|
135
|
+
if (codaA.length + 1 === codaB.length && codaA.every((p, i) => p === codaB[i])) return { type: 'augmented', structure };
|
|
136
|
+
if (codaB.length + 1 === codaA.length && codaB.every((p, i) => p === codaA[i])) return { type: 'diminished', structure };
|
|
137
|
+
return { type: 'assonant', structure };
|
|
138
|
+
}
|
|
139
|
+
if (codaEq && codaA.length > 0) return { type: 'consonant', structure };
|
|
140
|
+
const t = orthographicTier(a, b, pa, pb);
|
|
141
|
+
return t ? { type: t } : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Strength tiers for scheme detection.
|
|
145
|
+
const STRONG: Set<RhymeTypeName> = new Set(['identical', 'rich', 'perfect', 'family']);
|
|
146
|
+
const SLANT: Set<RhymeTypeName> = new Set(['assonant', 'consonant', 'augmented', 'diminished', 'wrenched', 'eye']);
|
|
147
|
+
|
|
148
|
+
export interface LineRhyme {
|
|
149
|
+
endWord: string;
|
|
150
|
+
letter: string; // scheme letter ('A', 'B', …; '·' = unrhymed)
|
|
151
|
+
type?: RhymeTypeName; // relation to the matched earlier line
|
|
152
|
+
matchedLine?: number; // 0-based index within the stanza
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Detect a stanza's rhyme scheme from its line-end words. Strong rhymes
|
|
156
|
+
* bind; slant-tier rhymes bind only when no strong candidate exists. */
|
|
157
|
+
export function detectScheme(endWords: string[]): LineRhyme[] {
|
|
158
|
+
const out: LineRhyme[] = [];
|
|
159
|
+
let nextLetter = 0;
|
|
160
|
+
for (let i = 0; i < endWords.length; i++) {
|
|
161
|
+
let best: { j: number; pair: RhymePair } | null = null;
|
|
162
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
163
|
+
const pair = classifyRhymePair(endWords[j], endWords[i]);
|
|
164
|
+
if (!pair) continue;
|
|
165
|
+
if (STRONG.has(pair.type)) { best = { j, pair }; break; } // nearest strong wins
|
|
166
|
+
if (!best && SLANT.has(pair.type)) best = { j, pair }; // else nearest slant
|
|
167
|
+
}
|
|
168
|
+
if (best) {
|
|
169
|
+
out.push({ endWord: endWords[i], letter: out[best.j].letter, type: best.pair.type, matchedLine: best.j });
|
|
170
|
+
} else {
|
|
171
|
+
out.push({ endWord: endWords[i], letter: String.fromCharCode(65 + (nextLetter++ % 26)) });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Rebind pass: a STRONG rhyme claims its partner back from an earlier
|
|
175
|
+
// slant-tier binding. (Sonnet 130: "rare" first slant-binds to the red/head
|
|
176
|
+
// group, then "compare" arrives as its perfect partner — the couplet wins.)
|
|
177
|
+
for (let k = 0; k < out.length; k++) {
|
|
178
|
+
const r = out[k];
|
|
179
|
+
if (r.matchedLine === undefined || !r.type || !STRONG.has(r.type)) continue;
|
|
180
|
+
const target = out[r.matchedLine];
|
|
181
|
+
if (target.matchedLine !== undefined && target.type && SLANT.has(target.type)) {
|
|
182
|
+
const fresh = String.fromCharCode(65 + (nextLetter++ % 26));
|
|
183
|
+
target.letter = fresh;
|
|
184
|
+
target.type = undefined;
|
|
185
|
+
target.matchedLine = undefined;
|
|
186
|
+
r.letter = fresh;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Lines whose letter never recurs are unrhymed: mark '·' for readability.
|
|
190
|
+
const counts = new Map<string, number>();
|
|
191
|
+
for (const r of out) counts.set(r.letter, (counts.get(r.letter) ?? 0) + 1);
|
|
192
|
+
for (const r of out) if ((counts.get(r.letter) ?? 0) < 2) r.letter = '·';
|
|
193
|
+
// Re-letter the survivors in order of first appearance (A, B, C…).
|
|
194
|
+
const remap = new Map<string, string>();
|
|
195
|
+
let k = 0;
|
|
196
|
+
for (const r of out) {
|
|
197
|
+
if (r.letter === '·') continue;
|
|
198
|
+
if (!remap.has(r.letter)) remap.set(r.letter, String.fromCharCode(65 + (k++ % 26)));
|
|
199
|
+
r.letter = remap.get(r.letter)!;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const schemeStr = (rs: LineRhyme[]) => rs.map(r => r.letter).join('');
|
|
205
|
+
|
|
206
|
+
/** Canonical scheme for FORM matching: every line gets a letter in sequential
|
|
207
|
+
* first-appearance order, unrhymed lines (·) each their own — so "·A·A"
|
|
208
|
+
* compares as "ABCB". */
|
|
209
|
+
function canonicalScheme(rs: LineRhyme[]): string {
|
|
210
|
+
const remap = new Map<string, string>();
|
|
211
|
+
let k = 0;
|
|
212
|
+
const next = () => String.fromCharCode(65 + (k++ % 26));
|
|
213
|
+
let out = '';
|
|
214
|
+
for (const r of rs) {
|
|
215
|
+
if (r.letter === '·') { out += next(); continue; }
|
|
216
|
+
if (!remap.has(r.letter)) remap.set(r.letter, next());
|
|
217
|
+
out += remap.get(r.letter)!;
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Stanza-level form verdict (LYRICAL-compatible names where they overlap). */
|
|
223
|
+
function stanzaForm(rhymes: LineRhyme[], details: PhonologicalScansionDetail[]): string | undefined {
|
|
224
|
+
const s = canonicalScheme(rhymes);
|
|
225
|
+
const n = rhymes.length;
|
|
226
|
+
const meters = details.map(d => (d.consensusMeter ?? d.meter).split(' ')[0]);
|
|
227
|
+
const dominant = (name: string, frac = 0.5) => meters.filter(m => m === name).length / n >= frac;
|
|
228
|
+
|
|
229
|
+
if (n === 2 && s === 'AA') return 'couplet';
|
|
230
|
+
if (n === 3) {
|
|
231
|
+
if (s === 'ABA') return 'triplet (tercet, ABA)';
|
|
232
|
+
if (s === 'AAA') return 'mono-rhymed triplet';
|
|
233
|
+
}
|
|
234
|
+
if (n === 4) {
|
|
235
|
+
// Beat counts for the ballad gate: footCount (classical) or ictus count.
|
|
236
|
+
const beats = details.map((d, i) =>
|
|
237
|
+
d.footCount > 0 ? d.footCount : ictusProfile(d.scansion).ictuses);
|
|
238
|
+
const alt43 = beats.length === 4 && beats[0] === beats[2] && beats[1] === beats[3]
|
|
239
|
+
&& beats[0] === beats[1] + 1;
|
|
240
|
+
if (s === 'ABAB' || s === 'ABCB') {
|
|
241
|
+
if (alt43) return `ballad stanza (${s}, ${beats[0]}·${beats[1]})`;
|
|
242
|
+
return s === 'ABAB' ? 'quatrain (cross-rhymed, ABAB)' : `ballad-rhyme quatrain (ABCB)`;
|
|
243
|
+
}
|
|
244
|
+
if (s === 'ABBA') return 'quatrain (envelope, ABBA)';
|
|
245
|
+
if (s === 'AABB') return 'quatrain (couplet pair, AABB)';
|
|
246
|
+
if (s === 'AAAA') return 'mono-rhymed quatrain';
|
|
247
|
+
}
|
|
248
|
+
if (n === 5 && s === 'AABBA') {
|
|
249
|
+
const ternary = meters.filter(m => m === 'anapestic' || m === 'amphibrachic' || m === 'dactylic').length >= 3;
|
|
250
|
+
return ternary ? 'limerick (AABBA, ternary)' : 'limerick rhyme (AABBA)';
|
|
251
|
+
}
|
|
252
|
+
if (n === 6 && s === 'ABABCC') return 'sextilla / Venus-and-Adonis stanza (ABABCC)';
|
|
253
|
+
if (n === 7 && s === 'ABABBCC') return 'rhyme royal (ABABBCC)';
|
|
254
|
+
if (n === 8 && (s === 'ABABABCC' || s === 'ABABABAB')) return `octave (${s})`;
|
|
255
|
+
|
|
256
|
+
// Unrhymed stanza: blank verse when iambic pentameter dominates.
|
|
257
|
+
const unrhymed = rhymes.every(r => r.letter === '·');
|
|
258
|
+
if (unrhymed && n >= 3) {
|
|
259
|
+
const iambicPenta = details.filter(d =>
|
|
260
|
+
(d.consensusMeter ?? d.meter) === 'iambic pentameter').length / n;
|
|
261
|
+
if (iambicPenta >= 0.6) return 'blank verse';
|
|
262
|
+
if (details.every(d => d.meterName === 'free verse') || details.some(d => d.rhythmNote)) return undefined; // rhythm layer already speaks
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Poem-level form verdicts that span stanzas (sonnets, terza rima…). */
|
|
268
|
+
function poemForm(stanzas: { rhymes: LineRhyme[]; details: PhonologicalScansionDetail[] }[]): string | undefined {
|
|
269
|
+
const all = stanzas.flatMap(st => st.rhymes);
|
|
270
|
+
const n = all.length;
|
|
271
|
+
// Whole-poem scheme with stanza-local letters concatenated is NOT meaningful;
|
|
272
|
+
// re-detect across the full poem for sonnet/terza checks.
|
|
273
|
+
if (n === 14) {
|
|
274
|
+
const rs = detectScheme(all.map(r => r.endWord));
|
|
275
|
+
const s = canonicalScheme(rs);
|
|
276
|
+
if (/^ABABCDCDEFEFGG$/.test(s)) return 'Shakespearean Sonnet';
|
|
277
|
+
if (/^ABBAABBA/.test(s)) return 'Petrarchan Sonnet';
|
|
278
|
+
if (s.endsWith('GG') || /(..)\1*..$/.test(s)) {
|
|
279
|
+
// 14 iambic-pentameter lines with a closing couplet still reads sonnet-like.
|
|
280
|
+
const last2 = rs[12].letter !== '·' && rs[12].letter === rs[13].letter;
|
|
281
|
+
const iambicPenta = stanzas.flatMap(st => st.details)
|
|
282
|
+
.filter(d => (d.consensusMeter ?? d.meter) === 'iambic pentameter').length / 14;
|
|
283
|
+
if (last2 && iambicPenta >= 0.5) return 'sonnet (14 lines, closing couplet)';
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Terza rima: chained tercets ABA BCB CDC…
|
|
287
|
+
if (stanzas.length >= 3 && stanzas.every(st => st.rhymes.length === 3)) {
|
|
288
|
+
let chained = true;
|
|
289
|
+
for (let i = 0; i + 1 < stanzas.length && chained; i++) {
|
|
290
|
+
const mid = stanzas[i].rhymes[1].endWord;
|
|
291
|
+
const nxt = stanzas[i + 1].rhymes;
|
|
292
|
+
const p1 = classifyRhymePair(mid, nxt[0].endWord);
|
|
293
|
+
const p3 = classifyRhymePair(mid, nxt[2].endWord);
|
|
294
|
+
if (!(p1 && STRONG.has(p1.type)) || !(p3 && STRONG.has(p3.type))) chained = false;
|
|
295
|
+
}
|
|
296
|
+
if (chained) return 'terza rima (ABA BCB CDC…)';
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Last syllable-bearing word of a line (across its merged sentences). */
|
|
302
|
+
function lineEndWord(line: LineResult): string {
|
|
303
|
+
const ws = line.sentence.words.filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0);
|
|
304
|
+
return ws.length ? ws[ws.length - 1].word : '';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Annotate rhyme letters/types (`detail.rhyme`) and form verdicts
|
|
309
|
+
* (`detail.formNote`) across a poem. Stanza forms are per-stanza; a poem-level
|
|
310
|
+
* form (sonnet, terza rima) overrides stanza notes. Annotation-only — no
|
|
311
|
+
* meter/scansion/certainty is touched.
|
|
312
|
+
*/
|
|
313
|
+
export function applyRhymeAndForm(stanzas: LineResult[][]): void {
|
|
314
|
+
const analyzed = stanzas.map(lines => {
|
|
315
|
+
const details = lines.map(l => l.phonologicalScansion);
|
|
316
|
+
const rhymes = detectScheme(lines.map(lineEndWord));
|
|
317
|
+
return { details, rhymes };
|
|
318
|
+
});
|
|
319
|
+
for (const { details, rhymes } of analyzed) {
|
|
320
|
+
const form = stanzaForm(rhymes, details);
|
|
321
|
+
for (let i = 0; i < details.length; i++) {
|
|
322
|
+
details[i].rhyme = rhymes[i];
|
|
323
|
+
details[i].formNote = form;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const pform = poemForm(analyzed);
|
|
327
|
+
if (pform) for (const { details } of analyzed) for (const d of details) d.formNote = pform;
|
|
328
|
+
}
|