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/stress.ts
ADDED
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
// stress.ts — Lexical, compound, nuclear stress assignment using nounsing-pro
|
|
2
|
+
// (augmented CMU dictionary with 52+ columns), then conversion to McAleese's
|
|
3
|
+
// 4‑level relative system.
|
|
4
|
+
|
|
5
|
+
import * as nounsing from 'nounsing-pro';
|
|
6
|
+
import { ClsWord, Syllable, StressLevel, IntonationalUnit, PhonologicalPhrase } from './types.js';
|
|
7
|
+
import { isPunctuation } from './parser.js';
|
|
8
|
+
import { collectPPTokens, syllabifyWord } from './phonological.js';
|
|
9
|
+
|
|
10
|
+
// ─── CONSTANTS & CLASSIFICATIONS ──────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Content‑word POS tags (nouns, adjectives, lexical verbs, adverbs).
|
|
14
|
+
* Excludes:
|
|
15
|
+
* - determiners (including demonstratives) – function words
|
|
16
|
+
* - possessive pronouns (PRP$) – function words
|
|
17
|
+
* - Wh‑words (WDT, WP, WP$, WRB) – function words
|
|
18
|
+
* - prepositions, conjunctions, particles, etc.
|
|
19
|
+
*/
|
|
20
|
+
const CONTENT_POS = new Set([
|
|
21
|
+
'NN', 'NNS', 'NNP', 'NNPS', // nouns
|
|
22
|
+
'JJ', 'JJR', 'JJS', // adjectives
|
|
23
|
+
'VB', 'VBD', 'VBG', 'VBN', 'VBP', 'VBZ', // lexical verbs (excludes modals MD)
|
|
24
|
+
'RB', 'RBR', 'RBS', // adverbs
|
|
25
|
+
'CD', // cardinal numbers (content-like)
|
|
26
|
+
'PDT', // predeterminers / quantifiers ("all", "both", "half") — carry quantificational stress
|
|
27
|
+
'RP', // phrasal-verb particles ("coming IN", "take OFF") — they bear the phrasal stress
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Spatial words that act as phrasal-verb PARTICLES (stress-bearing: "coming IN",
|
|
32
|
+
* "moving ON", "give UP") as opposed to prepositions ("in the house" → reduced).
|
|
33
|
+
* The parser usually tags a true particle `RP` (handled by CONTENT_POS above),
|
|
34
|
+
* but often mis-tags it `IN`/`RB` with an adverbial/particle dependency on the
|
|
35
|
+
* verb — `isPhrasalParticle` recovers those. A genuine preposition keeps a
|
|
36
|
+
* `prep`/`pobj` dependency on a NOUN and is (correctly) left as a function word.
|
|
37
|
+
*/
|
|
38
|
+
const PARTICLE_LEMMAS = new Set([
|
|
39
|
+
'in', 'on', 'out', 'off', 'up', 'down', 'over', 'away', 'back',
|
|
40
|
+
'along', 'around', 'about', 'through', 'apart', 'aside', 'forth', 'together',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** A phrasal-verb particle the parser tagged IN/RB (not RP): stress-bearing. */
|
|
44
|
+
function isPhrasalParticle(word: ClsWord): boolean {
|
|
45
|
+
if (word.lexicalClass === 'RP') return true;
|
|
46
|
+
const dep = word.dependency?.dependentType;
|
|
47
|
+
return PARTICLE_LEMMAS.has(word.word.toLowerCase())
|
|
48
|
+
&& (dep === 'prt' || dep === 'advmod');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Demonstratives that, used pronominally (not determining a following noun),
|
|
52
|
+
* are a stressed focus: "What's THAT?", "Give me THIS." */
|
|
53
|
+
const DEMONSTRATIVE_LEMMAS = new Set(['that', 'this', 'these', 'those']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A demonstrative used as a *pronoun* (the clause-final focus), rather than as a
|
|
57
|
+
* determiner of a following noun. The parser often tags focus "that" as `IN`
|
|
58
|
+
* (complementizer) and it then reduces to `x` — but "What's THAT?" puts the
|
|
59
|
+
* sentence's prominence on it. Detected as a demonstrative lemma that is the
|
|
60
|
+
* last non-punctuation word of the line (so "Is THAT grass" — a determiner — is
|
|
61
|
+
* untouched).
|
|
62
|
+
*/
|
|
63
|
+
function isFocusDemonstrative(words: ClsWord[], wi: number): boolean {
|
|
64
|
+
if (!DEMONSTRATIVE_LEMMAS.has(words[wi].word.toLowerCase())) return false;
|
|
65
|
+
for (let k = wi + 1; k < words.length; k++) {
|
|
66
|
+
if (!isPunctuation(words[k].lexicalClass)) return false; // a word follows → determiner use
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Clitic POS categories: function words that are *proclitic* — prepositions /
|
|
73
|
+
* subordinators (IN), infinitival "to" (TO), possessive determiners (PRP$) and
|
|
74
|
+
* wh-determiners/possessives/adverbs (WDT/WP$/WRB). A
|
|
75
|
+
* *monosyllabic* word in one of these classes is reduced in running speech
|
|
76
|
+
* (Selkirk's clitic; McAleese's "beginnings free") and should floor at 'w'
|
|
77
|
+
* (overt-weak, *promotable*), never 'n'. Leaving a CMU-primary monosyllable
|
|
78
|
+
* like "on"/"my"/"where"/"from" at 'n' produced flat function-word runs (Pound's
|
|
79
|
+
* "So on my" = n·n·n, "where strange" = n·n).
|
|
80
|
+
*
|
|
81
|
+
* Deliberately EXCLUDES:
|
|
82
|
+
* - modals (MD: "shall"/"might") and personal pronouns (PRP: "I"/"thee"/"you")
|
|
83
|
+
* — they carry real stress (the clause-final beat in "…fast as you MIGHT").
|
|
84
|
+
* - determiners (DT) entirely. The quantificational / negative / demonstrative
|
|
85
|
+
* ones ("no one", "all verse", "this", "each") carry stress (cf. the
|
|
86
|
+
* maintainer's PDT-as-content rule); and flooring even the pure articles
|
|
87
|
+
* a/an tips Tarlinskaja's razor-thin iambic↔anapestic line ("…else a
|
|
88
|
+
* laugher's license", margin 0.009) into a wrong meter. "the" already reads
|
|
89
|
+
* 'x' via its CMU-0 stress, so determiners need no extra handling here.
|
|
90
|
+
* - coordinators (CC: and/but/or). "and" already reads 'x' (CMU-0); flooring
|
|
91
|
+
* "but"→w fires earlier than its baseline path and ripples to suppress an
|
|
92
|
+
* adjacent pronoun's clash-promotion, tipping Tarlinskaja's razor-thin
|
|
93
|
+
* iambic↔anapestic line — and coordinators bear no part of the n-run problem.
|
|
94
|
+
* Polysyllabic function words are also untouched, so their internal contour is
|
|
95
|
+
* preserved (be·NEATH = x·n, un·der·NEATH = x·x·n).
|
|
96
|
+
*/
|
|
97
|
+
// NB: deliberately NOT including DT/PRP/MD/WP here. Monosyllabic pronouns,
|
|
98
|
+
// determiners, and modals at 'n' are PROMOTABLE into metrical beats — which
|
|
99
|
+
// real iambic verse exploits constantly ("but HE gave NO one ELSE",
|
|
100
|
+
// "fast as you MIGHT"). Flooring them to 'w' was tried (2026-06-12) and
|
|
101
|
+
// flipped the Mandelstam anapest and the Tarlinskaja iambic: reverted.
|
|
102
|
+
const CLITIC_POS = new Set(['IN', 'TO', 'PRP$', 'WDT', 'WP$', 'WRB']);
|
|
103
|
+
|
|
104
|
+
/** A reducible monosyllabic proclitic (see CLITIC_POS). */
|
|
105
|
+
function isMonosyllabicClitic(word: ClsWord): boolean {
|
|
106
|
+
return !word.isContent
|
|
107
|
+
&& word.syllables.length === 1
|
|
108
|
+
&& CLITIC_POS.has(word.lexicalClass);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Temporal, locative, and discourse adverbs that behave as function words
|
|
113
|
+
* in verse — they typically occupy weak metrical positions and should not
|
|
114
|
+
* receive the primary-stress treatment of content adverbs.
|
|
115
|
+
*/
|
|
116
|
+
const FUNCTION_ADVERBS = new Set([
|
|
117
|
+
'then', 'so', 'here', 'there', 'where', 'when', 'why', 'how',
|
|
118
|
+
'thus', 'hence', 'thence', 'whence',
|
|
119
|
+
'now', 'ago', 'afterwards', 'afterward', 'beforehand',
|
|
120
|
+
'meanwhile', 'nevertheless', 'nonetheless', 'however',
|
|
121
|
+
'therefore', 'furthermore', 'moreover',
|
|
122
|
+
'besides', 'instead', 'rather',
|
|
123
|
+
'quite', 'almost', 'nearly', 'just', 'only',
|
|
124
|
+
'even', 'also', 'too', 'very', 'indeed',
|
|
125
|
+
'already', 'yet', 'still', 'again', 'ever', 'never',
|
|
126
|
+
'always', 'often', 'sometimes', 'usually',
|
|
127
|
+
'today', 'tomorrow', 'yesterday', 'tonight',
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Oblique (object/dative) pronouns. In clause-final position these are
|
|
132
|
+
* canonically unstressed and do NOT attract the beat ("…and beHIND me", not
|
|
133
|
+
* "…and behind ME"), unlike a clause-final modal or content word. Used to keep
|
|
134
|
+
* the "endings strict" upbeat rule from promoting a final object pronoun.
|
|
135
|
+
*/
|
|
136
|
+
const OBLIQUE_PRONOUNS = new Set([
|
|
137
|
+
'me', 'him', 'her', 'us', 'them', 'thee', 'ye',
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Subject-pronoun contractions (pronoun + auxiliary). These are a known data
|
|
142
|
+
* anomaly: FinNLP mis-tags the line-initial "I"-forms as FW, and nounsing records
|
|
143
|
+
* "i'm" with stress 0 while its sibling "i'll" gets 1 — so "I'm" sank to 'x'
|
|
144
|
+
* (Zero-Provision) whereas "I'll" read 'n'. A contracted subject pronoun is an
|
|
145
|
+
* overt syllable, never a maximally-reduced clitic, so a dictionary-zero one is
|
|
146
|
+
* restamped to its siblings' weak stress (below). This is a TARGETED fix for that
|
|
147
|
+
* specific inconsistency — it does NOT change how clitics, prepositions, articles,
|
|
148
|
+
* or bare pronouns floor (those keep their broad 'x'/contour behaviour).
|
|
149
|
+
*/
|
|
150
|
+
const PRONOUN_SUBJECT_CONTRACTIONS = new Set([
|
|
151
|
+
"i'm", "i'll", "i've", "i'd",
|
|
152
|
+
"you're", "you'll", "you've", "you'd",
|
|
153
|
+
"he'll", "he'd", "she'll", "she'd", "it'll",
|
|
154
|
+
"we're", "we'll", "we've", "we'd",
|
|
155
|
+
"they're", "they'll", "they've", "they'd",
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Poetic aphaeresis / clipping forms (apostrophe-stripped, lowercased) of
|
|
160
|
+
* function words — prepositions and adverbs. These are OOV, so without special
|
|
161
|
+
* handling they default to a *stressed content* reading (the parser tags
|
|
162
|
+
* "'neath" as NNP → primary stress!). Only applied when an apostrophe is
|
|
163
|
+
* actually present (so a literal "mid"/"side"/"cross" is left alone).
|
|
164
|
+
*/
|
|
165
|
+
const APHAERESIS_CLITICS = new Set([
|
|
166
|
+
'neath', // beneath
|
|
167
|
+
'gainst', // against
|
|
168
|
+
'twixt', // betwixt
|
|
169
|
+
'mid', // amid
|
|
170
|
+
'midst', // amidst
|
|
171
|
+
'mongst', // amongst
|
|
172
|
+
'tween', // between
|
|
173
|
+
'pon', // upon
|
|
174
|
+
'oer', // o'er = over
|
|
175
|
+
'neer', // ne'er = never
|
|
176
|
+
'eer', // e'er = ever
|
|
177
|
+
'tis', // 'tis = it is (apostrophe-guarded, so a literal "tis" is untouched)
|
|
178
|
+
'twas', // 'twas = it was
|
|
179
|
+
'twere', // 'twere = it were
|
|
180
|
+
'twill', // 'twill = it will (guard protects the fabric "twill")
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Augmented-CMU data anomalies: for a couple of very common monosyllables the
|
|
185
|
+
* dictionary's ONLY profile is the letter-name spelling pronunciation of an
|
|
186
|
+
* abbreviation homograph — "am" → "EY1 EH1 M" (= A.M.), "us" → "Y UW1 EH1 S"
|
|
187
|
+
* (= U.S.) — inflating the syllable count of any line containing them. We
|
|
188
|
+
* restore the ordinary CMU citation form (AE1 M / AH1 S: one heavy syllable,
|
|
189
|
+
* citation stress 1) before the dictionary is consulted.
|
|
190
|
+
*/
|
|
191
|
+
const ANOMALOUS_MONOSYLLABLES: Record<string, { syllab: string; stress?: number; weight?: 'H' | 'L' }> = {
|
|
192
|
+
am: { syllab: '(AE m)' },
|
|
193
|
+
us: { syllab: '(AH s)' },
|
|
194
|
+
// "a" = letter-name "EY1" in the augmented dictionary; the article is the
|
|
195
|
+
// canonical Zero-Provision clitic (schwa, open syllable) → stress 0, light.
|
|
196
|
+
a: { syllab: '(AH)', stress: 0, weight: 'L' },
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Copula, auxiliary, aspectual, and light verbs that act as function words
|
|
201
|
+
* in verse — they do not carry the main semantic or prosodic weight of a phrase
|
|
202
|
+
* and should not be treated as content words for stress rules.
|
|
203
|
+
*/
|
|
204
|
+
const FUNCTION_VERBS = new Set([
|
|
205
|
+
'be', 'am', 'is', 'are', 'was', 'were', 'been', 'being',
|
|
206
|
+
'have', 'has', 'had', 'having',
|
|
207
|
+
'do', 'does', 'did', 'done', 'doing',
|
|
208
|
+
'get', 'gets', 'got', 'getting', 'gotten',
|
|
209
|
+
'start', 'starts', 'started', 'starting',
|
|
210
|
+
'begin', 'begins', 'began', 'beginning', 'begun',
|
|
211
|
+
'keep', 'keeps', 'kept', 'keeping',
|
|
212
|
+
'stop', 'stops', 'stopped', 'stopping',
|
|
213
|
+
'continue', 'continues', 'continued', 'continuing',
|
|
214
|
+
'let', 'lets', "let's"
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
/** Left‑stressed compound categories with example first‑word lists. */
|
|
218
|
+
const LEFT_STRESS_MATERIAL = new Set([
|
|
219
|
+
'metal', 'wood', 'silk', 'cotton', 'glass', 'stone', 'iron', 'steel',
|
|
220
|
+
'paper', 'plastic', 'gold', 'silver'
|
|
221
|
+
]);
|
|
222
|
+
const LEFT_STRESS_TIME = new Set([
|
|
223
|
+
'morning', 'evening', 'summer', 'winter', 'spring', 'autumn',
|
|
224
|
+
'christmas', 'easter', 'night', 'day'
|
|
225
|
+
]);
|
|
226
|
+
const LEFT_STRESS_MEASURE = new Set(['pint', 'dollar', 'foot', 'mile']);
|
|
227
|
+
const LEFT_STRESS_LOCATION = new Set([
|
|
228
|
+
'city', 'mountain', 'river', 'street', 'valley', 'island',
|
|
229
|
+
'town', 'village', 'country'
|
|
230
|
+
]);
|
|
231
|
+
const LEFT_STRESS_SELF = new Set(['self']);
|
|
232
|
+
|
|
233
|
+
// "Discard / ruin / spectral" noun-modifiers (N1) that reliably forestress as
|
|
234
|
+
// compounds: WASTE·land, SCRAP·yard, JUNK·yard, GHOST·town, DEAD·line,
|
|
235
|
+
// DUST·bowl, GRAVE·yard, BONE·yard, DEATH·bed. (Eliot's "WASTE shore".)
|
|
236
|
+
const LEFT_STRESS_DISCARD = new Set([
|
|
237
|
+
'waste', 'scrap', 'junk', 'ghost', 'dead', 'dust', 'grave', 'bone',
|
|
238
|
+
'death', 'trash', 'garbage', 'ash', 'blood', 'rust', 'wreck',
|
|
239
|
+
]);
|
|
240
|
+
// Elemental / landscape noun-modifiers (N1) that reliably forestress:
|
|
241
|
+
// SEA·shore, MOON·light, STORM·cloud, WIND·mill, FIRE·place, SALT·marsh,
|
|
242
|
+
// FROST·bite, SAND·bar, SNOW·flake, TIDE·water, SHADOW·land.
|
|
243
|
+
const LEFT_STRESS_ELEMENTAL = new Set([
|
|
244
|
+
'sea', 'moon', 'sun', 'star', 'storm', 'wind', 'fire', 'rain', 'snow',
|
|
245
|
+
'ice', 'tide', 'wave', 'frost', 'mist', 'fog', 'mud', 'sand', 'salt',
|
|
246
|
+
'earth', 'sky', 'dawn', 'dusk', 'shadow', 'flame', 'ember', 'smoke',
|
|
247
|
+
'cloud', 'water', 'dew', 'hail', 'marsh', 'moor', 'flood', 'foam',
|
|
248
|
+
]);
|
|
249
|
+
// Fire / light-source N1 modifiers that forestress like the elemental set:
|
|
250
|
+
// TORCH·light, CANDLE·light, LAMP·light, LANTERN·light, BEACON·fire,
|
|
251
|
+
// HEARTH·stone, COAL·fire, GAS·light — and Pound's hyphenated TORCH·flames
|
|
252
|
+
// (parallel to WASTE·shore; "flames" is the head, "torch" the modifier).
|
|
253
|
+
const LEFT_STRESS_FIRELIGHT = new Set([
|
|
254
|
+
'torch', 'candle', 'lamp', 'lantern', 'beacon', 'hearth', 'coal', 'gas',
|
|
255
|
+
]);
|
|
256
|
+
// Vehicle / conveyance N1 modifiers that forestress: SLEIGH·bells/blades,
|
|
257
|
+
// CART·wheel, WAGON·train, CAR·door, TRAIN·station, BOAT·house, TROLLEY·tickets.
|
|
258
|
+
// (Endocentric N+N where N1 is the conveyance the N2 belongs to / is part of.)
|
|
259
|
+
const LEFT_STRESS_VEHICLE = new Set([
|
|
260
|
+
'sleigh', 'sled', 'cart', 'wagon', 'carriage', 'coach', 'train', 'tram',
|
|
261
|
+
'trolley', 'car', 'boat', 'ship', 'plane', 'truck', 'bus', 'bike', 'bicycle',
|
|
262
|
+
]);
|
|
263
|
+
// Head nouns (N2) that keep phrasal/right stress even after a forestress
|
|
264
|
+
// modifier — chiefly food "made of N1" and a few lexical exceptions:
|
|
265
|
+
// apple PIE, summer DAY, Fifth AVenue. These carve-outs keep the rule honest
|
|
266
|
+
// (a wrong forestress would mis-teach learners), so they OVERRIDE the N1 sets.
|
|
267
|
+
const RIGHT_STRESS_HEADS = new Set([
|
|
268
|
+
'pie', 'cake', 'tart', 'pudding', 'mousse', 'soup', 'salad', 'sauce',
|
|
269
|
+
'juice', 'avenue', 'day',
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
/** Check if a pair of words forms a left‑stressed compound. */
|
|
273
|
+
function isLeftStressedPair(w1: string, w2: string): boolean {
|
|
274
|
+
const first = w1.toLowerCase();
|
|
275
|
+
const second = w2.toLowerCase().replace(/'s$/, '');
|
|
276
|
+
// A right-stressing head overrides any forestress modifier (apple PIE).
|
|
277
|
+
if (RIGHT_STRESS_HEADS.has(second)) return false;
|
|
278
|
+
if (LEFT_STRESS_MATERIAL.has(first)) return true;
|
|
279
|
+
if (LEFT_STRESS_TIME.has(first)) return true;
|
|
280
|
+
if (LEFT_STRESS_MEASURE.has(first)) return true;
|
|
281
|
+
if (LEFT_STRESS_LOCATION.has(first)) return true;
|
|
282
|
+
if (LEFT_STRESS_SELF.has(first)) return true;
|
|
283
|
+
if (LEFT_STRESS_DISCARD.has(first)) return true;
|
|
284
|
+
if (LEFT_STRESS_ELEMENTAL.has(first)) return true;
|
|
285
|
+
if (LEFT_STRESS_FIRELIGHT.has(first)) return true;
|
|
286
|
+
if (LEFT_STRESS_VEHICLE.has(first)) return true;
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Lexicalised forestress COLLOCATIONS — fixed two-word phrases that stress the
|
|
292
|
+
* LEFT element, even though the second word is not a noun (so the N+N/J+N
|
|
293
|
+
* Compound Stress Rule does not reach them). "GOOD old days/friend";
|
|
294
|
+
* "the be-all and END-all". Each entry's optional guard suppresses spurious
|
|
295
|
+
* firing (e.g. "End ALL the wars" — the *verb* "end" + quantifier "all the
|
|
296
|
+
* wars" — must NOT forestress; there "all" is a predeterminer PDT).
|
|
297
|
+
*/
|
|
298
|
+
const LEFT_STRESS_COLLOCATIONS: { w1: string; w2: string; ok?: (b: ClsWord) => boolean }[] = [
|
|
299
|
+
{ w1: 'good', w2: 'old' }, // GOOD old days
|
|
300
|
+
{ w1: 'end', w2: 'all', ok: w => w.lexicalClass !== 'PDT' }, // END-all (idiom), not "end ALL the wars"
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
/** True if (w1,w2) is a lexicalised forestress collocation in this context. */
|
|
304
|
+
function isLeftStressedCollocation(w1: ClsWord, w2: ClsWord): boolean {
|
|
305
|
+
const b1 = w1.word.toLowerCase().replace(/[^a-z]/g, '');
|
|
306
|
+
const b2 = w2.word.toLowerCase().replace(/[^a-z]/g, '');
|
|
307
|
+
for (const c of LEFT_STRESS_COLLOCATIONS) {
|
|
308
|
+
if (b1 === c.w1 && b2 === c.w2 && (!c.ok || c.ok(w2))) return true;
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── LEXICAL STRESS (pronouncingjs) ───────────────────────────────
|
|
314
|
+
|
|
315
|
+
const VOWEL_CHARS = new Set('aeiouyAEIOUY');
|
|
316
|
+
|
|
317
|
+
/** Archaic/locative pronominal compounds whose first element ends in a MEDIAL
|
|
318
|
+
* silent 'e' ("where·fore", "there·in"): the plain vowel-group count reads the
|
|
319
|
+
* 'e' as a nucleus and over-counts. Count the parts instead. */
|
|
320
|
+
const SILENT_E_COMPOUND_RE = /^(where|there|here)(fore|in|by|of|on|upon|at|to|with|out|after|under|unto|abouts?|soever)$/;
|
|
321
|
+
|
|
322
|
+
function countVowelGroups(word: string): number {
|
|
323
|
+
{
|
|
324
|
+
const m = word.toLowerCase().replace(/[^a-z]/g, '').match(SILENT_E_COMPOUND_RE);
|
|
325
|
+
// Closed-class second elements; counted directly ("fore" would otherwise
|
|
326
|
+
// read 2 — the small-word guard blocks the final-silent-e deduction).
|
|
327
|
+
if (m) return 1 + (m[2] === 'soever' ? 3
|
|
328
|
+
: /^(upon|after|under|unto|about)/.test(m[2]) ? 2 : 1);
|
|
329
|
+
}
|
|
330
|
+
const lower = word.toLowerCase().replace(/-/g, '').replace(/'s/g, '').replace(/'/g, '');
|
|
331
|
+
const n = lower.length;
|
|
332
|
+
let groups = 0;
|
|
333
|
+
let inVowel = false;
|
|
334
|
+
const vowelPositions: number[] = [];
|
|
335
|
+
for (let i = 0; i < n; i++) {
|
|
336
|
+
if (VOWEL_CHARS.has(lower[i])) {
|
|
337
|
+
if (!inVowel) { groups++; vowelPositions.push(i); inVowel = true; }
|
|
338
|
+
} else {
|
|
339
|
+
inVowel = false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (groups >= 3 && n > 2 && lower[n - 1] === 'e' && VOWEL_CHARS.has(lower[n - 1])) {
|
|
343
|
+
const lastVowelStart = vowelPositions[vowelPositions.length - 1];
|
|
344
|
+
if (lastVowelStart === n - 1) {
|
|
345
|
+
groups--;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return groups;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── OUT-OF-VOCABULARY STRESS (two-tier fallback) ─────────────────
|
|
352
|
+
//
|
|
353
|
+
// When a word is absent from the augmented CMU dictionary, the old fallback
|
|
354
|
+
// blindly forestressed it (primary on syllable 0). That mis-stresses the most
|
|
355
|
+
// common OOV case — *inflected/derived forms of common words* whose base IS in
|
|
356
|
+
// the lexicon ("voyaging" OOV, "voyage" present) — and many true OOV words too
|
|
357
|
+
// ("anfractuous" → AN·fractuous rather than an·FRAC·tuous). We replace it with:
|
|
358
|
+
// (1) MORPHOLOGICAL decomposition — strip a stress-neutral productive suffix,
|
|
359
|
+
// reconstruct the stem's orthography, look it up, and reuse the stem's
|
|
360
|
+
// *real* lexical stress (the suffix syllables are unstressed).
|
|
361
|
+
// (2) the English Stress Rule (quantity-sensitive) for the genuine residual
|
|
362
|
+
// (names, neologisms) with no recognisable stem.
|
|
363
|
+
// Both run ONLY in the OOV branch, so in-vocabulary scansion is untouched.
|
|
364
|
+
|
|
365
|
+
/** Strip one trailing doubled consonant (run·ning → run, stop·ped → stop). */
|
|
366
|
+
function deDouble(b: string): string {
|
|
367
|
+
const m = b.match(/([^aeiou])\1$/i);
|
|
368
|
+
return m ? b.slice(0, -1) : b;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** True if a stem ends in a sibilant/affricate, so a following -s/-es is its own
|
|
372
|
+
* syllable (kiss·es, box·es, voy·a·ges) rather than a bare coda (cats). */
|
|
373
|
+
function isSibilantEnd(s: string): boolean {
|
|
374
|
+
return /(s|z|x|sh|ch|ce|ge|se|ze|dge|tch)$/i.test(s);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Stress-neutral productive suffixes (Hayes: these do not shift the stem's
|
|
379
|
+
* stress). `stems(base)` lists candidate stem spellings to try (order = most
|
|
380
|
+
* likely first); `added(stem)` is how many *syllables* the suffix contributes.
|
|
381
|
+
* Stress-SHIFTING suffixes (-ion/-ity/-ic/-ial/-ious/-ify…) are deliberately
|
|
382
|
+
* omitted — treating them as neutral would mis-place the peak; they fall through
|
|
383
|
+
* to the English Stress Rule (and are common enough to usually be in-lexicon).
|
|
384
|
+
*/
|
|
385
|
+
const SUFFIX_RULES: { suffix: string; stems: (b: string) => string[]; added: (stem: string) => number }[] = [
|
|
386
|
+
{ suffix: 'iness', stems: b => [b + 'y'], added: () => 1 }, // happi·ness ← happy
|
|
387
|
+
{ suffix: 'ily', stems: b => [b + 'y'], added: () => 1 }, // happi·ly ← happy
|
|
388
|
+
{ suffix: 'ies', stems: b => [b + 'y'], added: () => 0 }, // car·ries ← carry
|
|
389
|
+
{ suffix: 'ied', stems: b => [b + 'y'], added: () => 0 }, // car·ried ← carry
|
|
390
|
+
{ suffix: 'ness', stems: b => [b], added: () => 1 },
|
|
391
|
+
{ suffix: 'ment', stems: b => [b], added: () => 1 },
|
|
392
|
+
{ suffix: 'less', stems: b => [b], added: () => 1 },
|
|
393
|
+
{ suffix: 'ful', stems: b => [b], added: () => 1 },
|
|
394
|
+
{ suffix: 'ings', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 },
|
|
395
|
+
{ suffix: 'ing', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 }, // voy·a·ging ← voyage
|
|
396
|
+
{ suffix: 'est', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 },
|
|
397
|
+
{ suffix: 'ed', stems: b => [b + 'e', b, deDouble(b)], added: stem => /[td]$/.test(stem) ? 1 : 0 },
|
|
398
|
+
{ suffix: 'eth', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 }, // archaic 3sg: go·eth, fall·eth, mak·eth
|
|
399
|
+
{ suffix: 'ith', stems: b => [b + 'y', b + 'e', b], added: () => 1 }, // archaic 3sg of -y verbs: sa·ith ← say
|
|
400
|
+
{ suffix: 'er', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 },
|
|
401
|
+
{ suffix: 'ly', stems: b => [b], added: () => 1 }, // soft·ly ← soft
|
|
402
|
+
{ suffix: 'es', stems: b => [b, b + 'e'], added: stem => isSibilantEnd(stem) ? 1 : 0 },
|
|
403
|
+
{ suffix: 's', stems: b => [b, b + 'e'], added: stem => isSibilantEnd(stem) ? 1 : 0 },
|
|
404
|
+
];
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Tier 1 — derive an OOV word's numeric stress (2=primary, 1=secondary, 0=none)
|
|
408
|
+
* by stripping a stress-neutral suffix and reusing the in-lexicon stem's stress.
|
|
409
|
+
* Returns null if no productive suffix yields a known stem.
|
|
410
|
+
*/
|
|
411
|
+
function morphologicalStress(w: string): { pattern: number[]; suffix: string } | null {
|
|
412
|
+
for (const rule of SUFFIX_RULES) {
|
|
413
|
+
if (!w.endsWith(rule.suffix)) continue;
|
|
414
|
+
const base = w.slice(0, w.length - rule.suffix.length);
|
|
415
|
+
if (base.length < 2) continue; // guard tiny stems (sing → s+ing)
|
|
416
|
+
for (const stem of rule.stems(base)) {
|
|
417
|
+
if (stem.length < 2) continue;
|
|
418
|
+
const data = nounsing.all(stem);
|
|
419
|
+
const raw = data && data.length ? (data[0].stress?.stressTrans || '') : '';
|
|
420
|
+
if (!raw) continue;
|
|
421
|
+
const stemNumeric = [...raw].map(c => mapCMUStress(parseInt(c, 10)));
|
|
422
|
+
if (stemNumeric.length === 0) continue;
|
|
423
|
+
const added = rule.added(stem);
|
|
424
|
+
return { pattern: [...stemNumeric, ...new Array(added).fill(0)], suffix: added >= 1 ? rule.suffix : '' };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Archaic verbal suffixes whose orthographic peel cleanly separates a silent-
|
|
431
|
+
* consonant stem from the suffix for DISPLAY (know·est not kno·west). Other
|
|
432
|
+
* suffixes keep the default orthographic syllabifier (it handles them well). */
|
|
433
|
+
const DISPLAY_PEEL_SUFFIXES = new Set(['est', 'eth', 'ith']);
|
|
434
|
+
|
|
435
|
+
/** Heavy syllable (orthographic estimate): long vowel (digraph/VCe) or closed
|
|
436
|
+
* by a coda consonant. Light = open with a single short vowel. */
|
|
437
|
+
function syllableIsHeavy(syl: string): boolean {
|
|
438
|
+
const s = syl.toLowerCase();
|
|
439
|
+
if (/[aeiouy]{2}/.test(s)) return true; // vowel digraph / diphthong → long
|
|
440
|
+
if (/[aeiou][^aeiouy]e$/.test(s)) return true; // V·C·e → long ("ate", "ime")
|
|
441
|
+
if (/[^aeiouy]$/.test(s)) return true; // closed syllable (coda present)
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Pre-stressing derivational suffixes (Hayes' "pre-stress 1/2"): they fix the
|
|
447
|
+
* primary on a syllable counted from the word's end (`offset` = syllables back,
|
|
448
|
+
* so primary index = n − offset). -ic/-tion fix the penult (offset 2),
|
|
449
|
+
* -ity/-graphy/-ical fix the antepenult (offset 3). Longest-match-first
|
|
450
|
+
* (enforced by the length sort below).
|
|
451
|
+
*
|
|
452
|
+
* The 2026-06-10 batch was DERIVED from the augmented CMU data itself
|
|
453
|
+
* (nounsing's `suffixType` shift classes cross-checked against the `mainStress`
|
|
454
|
+
* column over 3+-syllable words; every adopted ending ≥ 0.90 purity, most ≥ 0.96,
|
|
455
|
+
* N ≥ 60). This includes onomastic endings (-ski/-sky/-son/-berg/-gton …) that
|
|
456
|
+
* matter for OOV proper names — frequent in translation work. `-ary` is
|
|
457
|
+
* preantepenult and only fires on 4+-syllable words (the n ≥ offset guard),
|
|
458
|
+
* so BI-na-ry / ca-NA-ry style 3-syllable words fall through safely.
|
|
459
|
+
* NOTE: vowel-hiatus suffixes (-ion/-ial/-ious) can be undercounted by the
|
|
460
|
+
* orthographic syllable counter, so those stay approximate (documented limit).
|
|
461
|
+
*/
|
|
462
|
+
const PRESTRESS_SUFFIXES: { suffix: string; offset: number }[] = [
|
|
463
|
+
// hand-curated originals (Hayes)
|
|
464
|
+
{ suffix: 'graphy', offset: 3 }, { suffix: 'ically', offset: 4 },
|
|
465
|
+
{ suffix: 'ation', offset: 2 }, { suffix: 'ition', offset: 2 },
|
|
466
|
+
{ suffix: 'itude', offset: 3 }, { suffix: 'ical', offset: 3 },
|
|
467
|
+
{ suffix: 'logy', offset: 3 }, { suffix: 'nomy', offset: 3 },
|
|
468
|
+
{ suffix: 'cracy', offset: 3 }, { suffix: 'pathy', offset: 3 },
|
|
469
|
+
{ suffix: 'meter', offset: 3 }, { suffix: 'tion', offset: 2 },
|
|
470
|
+
{ suffix: 'sion', offset: 2 }, { suffix: 'ity', offset: 3 },
|
|
471
|
+
{ suffix: 'ety', offset: 3 }, { suffix: 'ify', offset: 3 },
|
|
472
|
+
{ suffix: 'ics', offset: 2 }, { suffix: 'ic', offset: 2 },
|
|
473
|
+
// data-derived 2026-06-10: final-stressing (ultShift)
|
|
474
|
+
{ suffix: 'ette', offset: 1 }, { suffix: 'ese', offset: 1 },
|
|
475
|
+
{ suffix: 'eer', offset: 1 }, { suffix: 'ique', offset: 1 },
|
|
476
|
+
// data-derived: penult-stressing
|
|
477
|
+
{ suffix: 'ion', offset: 2 }, { suffix: 'sive', offset: 2 },
|
|
478
|
+
{ suffix: 'lla', offset: 2 }, { suffix: 'llo', offset: 2 },
|
|
479
|
+
{ suffix: 'lli', offset: 2 }, { suffix: 'tti', offset: 2 },
|
|
480
|
+
{ suffix: 'ina', offset: 2 }, { suffix: 'ino', offset: 2 },
|
|
481
|
+
{ suffix: 'ano', offset: 2 }, { suffix: 'ana', offset: 2 },
|
|
482
|
+
{ suffix: 'ini', offset: 2 },
|
|
483
|
+
{ suffix: 'ski', offset: 2 }, { suffix: 'sky', offset: 2 },
|
|
484
|
+
// data-derived: antepenult-stressing
|
|
485
|
+
{ suffix: 'ate', offset: 3 }, { suffix: 'cal', offset: 3 },
|
|
486
|
+
{ suffix: 'onal', offset: 3 }, { suffix: 'nger', offset: 3 },
|
|
487
|
+
{ suffix: 'son', offset: 3 }, { suffix: 'man', offset: 3 },
|
|
488
|
+
{ suffix: 'berg', offset: 3 }, { suffix: 'gton', offset: 3 },
|
|
489
|
+
// data-derived: preantepenult-stressing (4+ syllables only via the guard)
|
|
490
|
+
{ suffix: 'ary', offset: 4 },
|
|
491
|
+
].sort((a, b) => b.suffix.length - a.suffix.length);
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Tier 2 — the English Stress Rule for genuine OOV (no recognisable stem).
|
|
495
|
+
* First honours a pre-stressing derivational suffix (terRIF·ic, ac·TIV·i·ty,
|
|
496
|
+
* pho·TOG·ra·phy). Otherwise it is quantity-sensitive with final-syllable
|
|
497
|
+
* extrametricality: monosyllables take primary; disyllables keep the English
|
|
498
|
+
* forestress default; for 3+ syllables the final is extrametrical and stress
|
|
499
|
+
* falls on a heavy penult, else the antepenult (Hayes 1982). This fixes e.g.
|
|
500
|
+
* an·FRAC·tuous / e·NIG·ma where blind forestress erred.
|
|
501
|
+
*/
|
|
502
|
+
function englishStressRule(w: string, isContent: boolean): number[] {
|
|
503
|
+
const n = countVowelGroups(w);
|
|
504
|
+
const primary = isContent ? 2 : 1;
|
|
505
|
+
if (n <= 1) return [primary];
|
|
506
|
+
for (const { suffix, offset } of PRESTRESS_SUFFIXES) {
|
|
507
|
+
if (w.endsWith(suffix) && n >= offset) {
|
|
508
|
+
const pattern = new Array(n).fill(0);
|
|
509
|
+
pattern[n - offset] = primary;
|
|
510
|
+
return pattern;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (n === 2) return [primary, 0]; // English disyllabic default (trochaic)
|
|
514
|
+
const sylls = syllabifyWord(w, n);
|
|
515
|
+
const pattern = new Array(n).fill(0);
|
|
516
|
+
const penult = n - 2; // final (n-1) is extrametrical
|
|
517
|
+
const heavyPenult = sylls[penult] ? syllableIsHeavy(sylls[penult]) : true;
|
|
518
|
+
pattern[heavyPenult ? penult : Math.max(0, n - 3)] = primary;
|
|
519
|
+
return pattern;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Per-syllable heaviness from nounsing's `syllStruct` CV transcription
|
|
524
|
+
* ("L.CL.CLC": C = consonant, L = lax/short nucleus, T = tense/long nucleus).
|
|
525
|
+
* Heavy = tense nucleus OR closed syllable (a coda consonant after the nucleus).
|
|
526
|
+
* Returns undefined when the segment count doesn't match the syllable count, so
|
|
527
|
+
* callers fall back to the orthographic estimate.
|
|
528
|
+
*/
|
|
529
|
+
function heavyFromSyllStruct(syllStruct: string | undefined, n: number): boolean[] | undefined {
|
|
530
|
+
if (!syllStruct) return undefined;
|
|
531
|
+
const segs = syllStruct.split('.');
|
|
532
|
+
if (segs.length !== n) return undefined;
|
|
533
|
+
return segs.map(seg => {
|
|
534
|
+
const vi = seg.search(/[LT]/);
|
|
535
|
+
if (vi < 0) return false;
|
|
536
|
+
return seg[vi] === 'T' || vi < seg.length - 1;
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The syllable index that should bear the default stress of a polysyllabic word
|
|
542
|
+
* whose dictionary entry records NO stress at all (an all-zero pattern — the
|
|
543
|
+
* maximally-reduced citation form of a few function words, chiefly "into"=00).
|
|
544
|
+
* Every lexical word bears at least one stress, so we restore it: a pre-stressing
|
|
545
|
+
* suffix fixes the count-from-end syllable; otherwise the English forestress
|
|
546
|
+
* default for disyllables (IN-to, ON-to), and the quantity-sensitive penult/
|
|
547
|
+
* antepenult (Hayes) for longer words. Mirrors englishStressRule's placement.
|
|
548
|
+
* `heavyFlags` (real per-syllable quantity from nounsing's syllStruct) replaces
|
|
549
|
+
* the orthographic heaviness guess when the word is in-vocabulary.
|
|
550
|
+
*/
|
|
551
|
+
function defaultStressIndex(word: string, n: number, heavyFlags?: boolean[]): number {
|
|
552
|
+
for (const { suffix, offset } of PRESTRESS_SUFFIXES) {
|
|
553
|
+
if (word.endsWith(suffix) && n >= offset) return n - offset;
|
|
554
|
+
}
|
|
555
|
+
if (n <= 2) return 0; // English disyllabic forestress default
|
|
556
|
+
const penult = n - 2; // final (n-1) extrametrical
|
|
557
|
+
const heavyPenult = heavyFlags
|
|
558
|
+
? heavyFlags[penult]
|
|
559
|
+
: (() => { const sylls = syllabifyWord(word, n); return sylls[penult] ? syllableIsHeavy(sylls[penult]) : true; })();
|
|
560
|
+
return heavyPenult ? penult : Math.max(0, n - 3);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Map CMU stress (0=unstressed, 1=primary, 2=secondary) to
|
|
565
|
+
* McAleese's numeric scale: 0=unstressed, 1=secondary, 2=primary.
|
|
566
|
+
*/
|
|
567
|
+
function mapCMUStress(cmuStress: number): number {
|
|
568
|
+
if (cmuStress === 1) return 2; // primary → 2
|
|
569
|
+
if (cmuStress === 2) return 1; // secondary → 1
|
|
570
|
+
return 0; // unstressed → 0
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Assign per‑syllable lexical stress to each word in a sentence.
|
|
575
|
+
*
|
|
576
|
+
* Uses the first CMU pronunciation. Function words have their
|
|
577
|
+
* primary stress downgraded to secondary (2 → 1).
|
|
578
|
+
*/
|
|
579
|
+
export function assignLexicalStress(words: ClsWord[]): void {
|
|
580
|
+
for (let wi = 0; wi < words.length; wi++) {
|
|
581
|
+
const word = words[wi];
|
|
582
|
+
if (isPunctuation(word.lexicalClass)) {
|
|
583
|
+
word.syllables = [];
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Explicitly assign 0 syllables to possessive/contraction clitic "'s"
|
|
588
|
+
if (word.word === "'s") {
|
|
589
|
+
word.syllables = [];
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Poetic aphaeresis clipping ('neath, o'er, 'gainst…) → treat as the reduced
|
|
594
|
+
// function word it stands for, instead of the OOV default (NNP → stressed).
|
|
595
|
+
// Guard on an actual apostrophe (split off as the prior token, or internal),
|
|
596
|
+
// so a literal "mid"/"side"/"cross" is untouched.
|
|
597
|
+
{
|
|
598
|
+
const bare = word.word.toLowerCase().replace(/['’]/g, '');
|
|
599
|
+
const hasApostrophe = /['’]/.test(word.word)
|
|
600
|
+
|| (wi > 0 && (words[wi - 1].word === "'" || words[wi - 1].word === '’'));
|
|
601
|
+
if (hasApostrophe && APHAERESIS_CLITICS.has(bare)) {
|
|
602
|
+
word.isContent = false;
|
|
603
|
+
// One weak monosyllable; lexical 0 + function ⇒ maps to 'x' (reduced clitic).
|
|
604
|
+
word.syllables = [{ text: word.word, phones: '', stress: 0, lexicalStress: 0 }];
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
let lookupWord = word.word.toLowerCase();
|
|
610
|
+
|
|
611
|
+
// Letter-name dictionary anomalies ("am" = A.M., "us" = U.S.): stamp the
|
|
612
|
+
// ordinary citation monosyllable directly (see ANOMALOUS_MONOSYLLABLES).
|
|
613
|
+
{
|
|
614
|
+
const fix = ANOMALOUS_MONOSYLLABLES[lookupWord];
|
|
615
|
+
if (fix) {
|
|
616
|
+
const isContent = isContentWord(word.lexicalClass, word.word) || isPhrasalParticle(word) || isFocusDemonstrative(words, wi);
|
|
617
|
+
word.isContent = isContent;
|
|
618
|
+
const numeric = fix.stress ?? (isContent ? 2 : 1); // citation primary; function words reduce to secondary
|
|
619
|
+
word.syllables = [{ text: word.word, phones: fix.syllab, weight: fix.weight ?? 'H', stress: numeric, lexicalStress: numeric }];
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Elided article fused to its host (th'expense, th'inconstant): "th'" is
|
|
625
|
+
// non-syllabic, so the HOST word's dictionary entry is the right source for
|
|
626
|
+
// stress and syllable count — otherwise the fused token goes OOV and takes
|
|
627
|
+
// the disyllabic forestress default (TH'EX-pense instead of th'ex-PENSE).
|
|
628
|
+
{
|
|
629
|
+
const m = lookupWord.match(/^th['’](.+)$/);
|
|
630
|
+
if (m && m[1].length >= 2) lookupWord = m[1];
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
let allData = nounsing.all(lookupWord);
|
|
634
|
+
if (!allData && lookupWord.includes('-')) {
|
|
635
|
+
const noHyphen = lookupWord.replace(/-/g, '');
|
|
636
|
+
allData = nounsing.all(noHyphen);
|
|
637
|
+
}
|
|
638
|
+
if ((!allData || allData.length === 0) && lookupWord.includes('-')) {
|
|
639
|
+
const parts = lookupWord.split('-');
|
|
640
|
+
const partStresses: string[] = [];
|
|
641
|
+
const partWeights: string[] = [];
|
|
642
|
+
for (const part of parts) {
|
|
643
|
+
const partData = nounsing.all(part);
|
|
644
|
+
if (partData && partData.length > 0) {
|
|
645
|
+
partStresses.push(partData[0].stress.stressTrans || '');
|
|
646
|
+
partWeights.push(partData[0].weightPattern || '');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (partStresses.length === parts.length && partStresses.every(s => s.length > 0)) {
|
|
650
|
+
const combinedStress = partStresses.join('');
|
|
651
|
+
const isContent = isContentWord(word.lexicalClass, word.word) || isPhrasalParticle(word) || isFocusDemonstrative(words, wi);
|
|
652
|
+
word.isContent = isContent;
|
|
653
|
+
const syls: Syllable[] = [];
|
|
654
|
+
for (let i = 0; i < combinedStress.length; i++) {
|
|
655
|
+
const cmu = parseInt(combinedStress[i], 10);
|
|
656
|
+
let numeric = mapCMUStress(cmu);
|
|
657
|
+
if (!isContent && numeric === 2) numeric = 1;
|
|
658
|
+
syls.push({ text: word.word, phones: '', stress: numeric, lexicalStress: numeric });
|
|
659
|
+
}
|
|
660
|
+
word.syllables = syls;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (!allData || allData.length === 0) {
|
|
665
|
+
const cleanWord = word.word.toLowerCase().replace(/-/g, '').replace(/['’]/g, '');
|
|
666
|
+
const isContent = isContentWord(word.lexicalClass, word.word) || isPhrasalParticle(word) || isFocusDemonstrative(words, wi);
|
|
667
|
+
word.isContent = isContent;
|
|
668
|
+
// Tier 1: morphological stem (reuse real lexical stress); Tier 2: ESR.
|
|
669
|
+
const morph = morphologicalStress(cleanWord);
|
|
670
|
+
const pattern = morph ? morph.pattern : englishStressRule(cleanWord, isContent);
|
|
671
|
+
// Record an archaic verbal suffix so the display splits know·est, not kno·west.
|
|
672
|
+
if (morph && DISPLAY_PEEL_SUFFIXES.has(morph.suffix)) word.morphSuffix = morph.suffix;
|
|
673
|
+
const syls: Syllable[] = pattern.map(numeric => {
|
|
674
|
+
// Mirror the in-vocab function-word reduction (primary → secondary).
|
|
675
|
+
const n = (!isContent && numeric === 2) ? 1 : numeric;
|
|
676
|
+
return { text: word.word, phones: '', stress: n, lexicalStress: n };
|
|
677
|
+
});
|
|
678
|
+
word.syllables = syls;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// For nouns with multiple pronunciations, prefer front‑stressed (noun form).
|
|
683
|
+
let profile = allData[0];
|
|
684
|
+
if (allData.length > 1 && word.lexicalClass.startsWith('N')) {
|
|
685
|
+
for (const p of allData) {
|
|
686
|
+
const stressStr = p.stress.stressTrans;
|
|
687
|
+
if (stressStr && stressStr.length > 0 && (stressStr[0] === '1' || stressStr[0] === '2')) {
|
|
688
|
+
profile = p;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
let rawStress = profile.stress.stressTrans || ''; // e.g., "010"
|
|
695
|
+
|
|
696
|
+
// The CMU syllabification is authoritative for the syllable count. The
|
|
697
|
+
// orthographic vowel-group count UNDER-counts vowel-hiatus / glide words
|
|
698
|
+
// (goo·ey, play·ers, be·ing each read as a single vowel run), so it must NOT
|
|
699
|
+
// truncate the dictionary's count — doing so collapsed those to one syllable.
|
|
700
|
+
// Only clamp when stressTrans is genuinely LONGER than the CMU
|
|
701
|
+
// syllabification (a rare data inconsistency).
|
|
702
|
+
const syllsMatch = (profile.phonology.syllabification || '').match(/\([^)]+\)/g) || [];
|
|
703
|
+
if (syllsMatch.length > 0 && rawStress.length > syllsMatch.length) {
|
|
704
|
+
rawStress = rawStress.slice(0, syllsMatch.length);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Synaeresis (verse vowel-gliding): an UNSTRESSED open syllable ending in a
|
|
708
|
+
// high-front vowel (IY/IH), followed by an UNSTRESSED vowel-initial syllable,
|
|
709
|
+
// glides into one syllable in verse — As·syr·i·an → as·syr·yan, var·i·ous →
|
|
710
|
+
// var·yous, glor·i·ous → glor·yous. It does NOT fire on a stressed nucleus
|
|
711
|
+
// (be·ing, i·DE·a) or before a stressed vowel (cre·ATE), so those keep their
|
|
712
|
+
// full count. Distinct from the (removed) orthographic truncation: it merges
|
|
713
|
+
// only genuine glide pairs, leaving goo·ey / play·ers / po·et intact.
|
|
714
|
+
if (syllsMatch.length === rawStress.length && rawStress.length >= 2) {
|
|
715
|
+
const tokensOf = (s: string) => s.replace(/[()]/g, '').trim().split(/\s+/).filter(Boolean);
|
|
716
|
+
const mStress: string[] = [];
|
|
717
|
+
const mSylls: string[] = [];
|
|
718
|
+
for (let i = 0; i < rawStress.length; i++) {
|
|
719
|
+
const cur = tokensOf(syllsMatch[i]);
|
|
720
|
+
const last = cur[cur.length - 1] ?? '';
|
|
721
|
+
const next = i + 1 < rawStress.length ? tokensOf(syllsMatch[i + 1]) : [];
|
|
722
|
+
if (i + 1 < rawStress.length
|
|
723
|
+
&& rawStress[i] === '0' && rawStress[i + 1] === '0'
|
|
724
|
+
&& (last === 'IY' || last === 'IH')
|
|
725
|
+
&& /^[AEIOU]/.test(next[0] ?? '')) {
|
|
726
|
+
mStress.push('0');
|
|
727
|
+
mSylls.push('(' + cur.concat(next).join(' ') + ')');
|
|
728
|
+
i++; // absorb the glided syllable
|
|
729
|
+
} else {
|
|
730
|
+
mStress.push(rawStress[i]);
|
|
731
|
+
mSylls.push(syllsMatch[i]);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
rawStress = mStress.join('');
|
|
735
|
+
syllsMatch.splice(0, syllsMatch.length, ...mSylls);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// All-zero CMU pattern on a polysyllabic word: restore the default stress.
|
|
739
|
+
// A handful of reduced function words (chiefly "into"=00) are recorded with
|
|
740
|
+
// NO stress at all, which left every syllable at 'x' (in·to = x·x) — both
|
|
741
|
+
// unlike careful usage (IN-to) and metrically inert. Every lexical word
|
|
742
|
+
// bears a stress, so we re-stamp a CMU primary on the default-stress syllable
|
|
743
|
+
// (forestress for disyllables); function-word demotion downstream turns this
|
|
744
|
+
// into a secondary, giving the natural IN-to contour. Only fires on the
|
|
745
|
+
// genuine all-zero artifact, so words that already carry a peak are untouched.
|
|
746
|
+
if (rawStress.length >= 2 && /^0+$/.test(rawStress)) {
|
|
747
|
+
const cw = word.word.toLowerCase().replace(/-/g, '').replace(/['’]/g, '');
|
|
748
|
+
const heavy = heavyFromSyllStruct(profile.phonology.syllStruct, rawStress.length);
|
|
749
|
+
const idx = defaultStressIndex(cw, rawStress.length, heavy);
|
|
750
|
+
rawStress = rawStress.split('').map((c, i) => (i === idx ? '1' : '0')).join('');
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Targeted fix for the "I'm" anomaly: a subject-pronoun contraction the
|
|
754
|
+
// dictionary records as fully unstressed ("i'm"=0, while "i'll"=1) is restamped
|
|
755
|
+
// to a weak (function) stress, so it reads like its siblings ('n') rather than
|
|
756
|
+
// sinking to Zero-Provision 'x'. Narrow by construction — only fires on a
|
|
757
|
+
// monosyllabic, genuinely all-zero pronoun contraction; everything else is
|
|
758
|
+
// left exactly as it was.
|
|
759
|
+
if (rawStress === '0' && PRONOUN_SUBJECT_CONTRACTIONS.has(lookupWord)) {
|
|
760
|
+
rawStress = '1';
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const isContent = isContentWord(word.lexicalClass, word.word) || isPhrasalParticle(word) || isFocusDemonstrative(words, wi);
|
|
764
|
+
word.isContent = isContent;
|
|
765
|
+
|
|
766
|
+
const syllables: Syllable[] = [];
|
|
767
|
+
const weightsArray = (profile.weightPattern || '').split(' ').filter(x => x === 'H' || x === 'L');
|
|
768
|
+
|
|
769
|
+
// Determine extrametricality classification for the final syllable.
|
|
770
|
+
// Uses Hayes (1980) constraints: only Light edge syllables, only noun final syllables,
|
|
771
|
+
// morphological s/z (plural/tense) markers, and derivational suffixes in adjectives.
|
|
772
|
+
const sClassifier = profile.S ?? '';
|
|
773
|
+
// Extrametricality is a property of nouns / derived adjectives. Key it off
|
|
774
|
+
// the word's actual sentence POS (from the parser), NOT nounsing's lexical
|
|
775
|
+
// pos — otherwise function words like the preposition "underneath" (which the
|
|
776
|
+
// CMU data may tag nominally) wrongly lose the stress on their final syllable.
|
|
777
|
+
const isNoun = word.lexicalClass.startsWith('N');
|
|
778
|
+
const isAdj = word.lexicalClass.startsWith('JJ');
|
|
779
|
+
const finalWeight = profile.weight.find(w => w.syllable === 'final')?.heaviness ?? '';
|
|
780
|
+
const nsylls = rawStress.length;
|
|
781
|
+
|
|
782
|
+
let extrametricalType: Syllable['extrametrical'] = undefined;
|
|
783
|
+
if (nsylls >= 2) {
|
|
784
|
+
if ((sClassifier === 'S' || sClassifier === 'SCluster') && isNoun) {
|
|
785
|
+
extrametricalType = 'morphological';
|
|
786
|
+
} else if (isNoun && finalWeight === 'L' && nsylls >= 3) {
|
|
787
|
+
extrametricalType = 'light_noun';
|
|
788
|
+
} else if (isAdj && profile.morphology.suffix === 'suffix') {
|
|
789
|
+
extrametricalType = 'derivational';
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const phonesTokens = profile.phonology.phones.split(' ');
|
|
794
|
+
let phoneIdx = 0;
|
|
795
|
+
|
|
796
|
+
for (let i = 0; i < rawStress.length; i++) {
|
|
797
|
+
const ch = rawStress[i];
|
|
798
|
+
const cmu = parseInt(ch, 10);
|
|
799
|
+
let numeric = mapCMUStress(cmu);
|
|
800
|
+
// Function words are reduced in running speech, but their INTERNAL stress
|
|
801
|
+
// contour must be preserved: demote the primary syllable to secondary AND
|
|
802
|
+
// the secondary syllables to none, so the lexical peak stays the peak.
|
|
803
|
+
// (Flattening primary→secondary alone would tie "un" and "neath" in
|
|
804
|
+
// "underneath", letting a later clash invert it to ÚN-der-neath.)
|
|
805
|
+
if (!isContent) {
|
|
806
|
+
if (numeric === 2) numeric = 1;
|
|
807
|
+
else if (numeric === 1) numeric = 0;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const wPatLen = weightsArray.length;
|
|
811
|
+
const rLen = rawStress.length;
|
|
812
|
+
const wIdx = wPatLen - (rLen - i);
|
|
813
|
+
const weight = wIdx >= 0 && wIdx < wPatLen ? weightsArray[wIdx] as 'H' | 'L' : 'L';
|
|
814
|
+
|
|
815
|
+
const sylTextMatch = syllsMatch[i];
|
|
816
|
+
const sylText = sylTextMatch ? sylTextMatch.replace(/[()]/g, '') : word.word;
|
|
817
|
+
|
|
818
|
+
const sylPhonesMatch = syllsMatch[i] || '';
|
|
819
|
+
const isLastSyl = i === rawStress.length - 1;
|
|
820
|
+
|
|
821
|
+
syllables.push({
|
|
822
|
+
text: sylText,
|
|
823
|
+
phones: sylPhonesMatch,
|
|
824
|
+
weight,
|
|
825
|
+
stress: numeric,
|
|
826
|
+
lexicalStress: numeric,
|
|
827
|
+
relativeStress: undefined,
|
|
828
|
+
extrametrical: isLastSyl ? extrametricalType : undefined,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (extrametricalType) {
|
|
833
|
+
word.lexicalDetails = `extrametrical_${extrametricalType}`;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// A focus demonstrative ("What's THAT?", "Give me THIS.") carries PRIMARY
|
|
837
|
+
// stress; CMU lists the weak/reduced (complementizer) form, which would leave
|
|
838
|
+
// it merely 'n' after the nuclear boost. Force its peak to primary so the
|
|
839
|
+
// sentence's prominence lands on it.
|
|
840
|
+
if (isFocusDemonstrative(words, wi) && syllables.length > 0) {
|
|
841
|
+
const pk = syllables.reduce((a, b) => (b.stress >= a.stress ? b : a));
|
|
842
|
+
pk.stress = 2;
|
|
843
|
+
pk.lexicalStress = 2;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
word.syllables = syllables;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ─── COMPOUND STRESS RULE ─────────────────────────────────────────
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Adjust stresses for nominal compounds.
|
|
854
|
+
*
|
|
855
|
+
* Right‑stressed by default: the second content word keeps primary (2),
|
|
856
|
+
* the first is reduced to secondary (1).
|
|
857
|
+
* Known left‑stressed compounds (material, time, measure, location, self)
|
|
858
|
+
* reverse the pattern.
|
|
859
|
+
*/
|
|
860
|
+
export function applyCompoundStress(ius: IntonationalUnit[]): void {
|
|
861
|
+
for (const iu of ius) {
|
|
862
|
+
for (const pp of iu.phonologicalPhrases) {
|
|
863
|
+
const words = collectPPTokens(pp);
|
|
864
|
+
// We don't want compound stress applied between arbitrary words across a phrase!
|
|
865
|
+
// Only apply to ADJACENT content words!
|
|
866
|
+
const contentWords = words.filter(w => w.isContent);
|
|
867
|
+
for (let i = 0; i < contentWords.length - 1; i++) {
|
|
868
|
+
const w1 = contentWords[i];
|
|
869
|
+
const w2 = contentWords[i + 1];
|
|
870
|
+
|
|
871
|
+
// Wait, they must be adjacent in the sentence!
|
|
872
|
+
if (Math.abs(w1.absoluteIndex - w2.absoluteIndex) !== 1) continue;
|
|
873
|
+
|
|
874
|
+
const pos1 = w1.lexicalClass;
|
|
875
|
+
const pos2 = w2.lexicalClass;
|
|
876
|
+
const isCompound =
|
|
877
|
+
(pos2.startsWith('N') && (pos1.startsWith('N') || pos1.startsWith('J')));
|
|
878
|
+
if (!isCompound) continue;
|
|
879
|
+
|
|
880
|
+
const leftStressed = isLeftStressedPair(w1.word, w2.word);
|
|
881
|
+
|
|
882
|
+
if (leftStressed) {
|
|
883
|
+
setPrimaryStress(w1, 2);
|
|
884
|
+
setPrimaryStress(w2, 1);
|
|
885
|
+
} else {
|
|
886
|
+
setPrimaryStress(w1, 1);
|
|
887
|
+
setPrimaryStress(w2, 2);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Locate the syllable with the highest stress and set it to `value`. */
|
|
895
|
+
function setPrimaryStress(word: ClsWord, value: number): void {
|
|
896
|
+
let maxIdx = -1;
|
|
897
|
+
let maxVal = -1;
|
|
898
|
+
for (let i = 0; i < word.syllables.length; i++) {
|
|
899
|
+
if (word.syllables[i].stress > maxVal) {
|
|
900
|
+
maxVal = word.syllables[i].stress;
|
|
901
|
+
maxIdx = i;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (maxIdx >= 0) {
|
|
905
|
+
word.syllables[maxIdx].stress = value;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ─── NUCLEAR STRESS RULE ──────────────────────────────────────────
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Recursively assign higher stress to content words from right to left.
|
|
913
|
+
* Only the rightmost content word receives a boost (+1 above lexical primary).
|
|
914
|
+
* All other content words keep their lexical stress.
|
|
915
|
+
* This preserves lexical stress for meter detection while still indicating
|
|
916
|
+
* the nuclear accent for phonological phrasing.
|
|
917
|
+
*/
|
|
918
|
+
export function applyNuclearStress(ius: IntonationalUnit[]): void {
|
|
919
|
+
for (const iu of ius) {
|
|
920
|
+
for (const pp of iu.phonologicalPhrases) {
|
|
921
|
+
const words = collectPPTokens(pp).sort((a, b) => a.index - b.index);
|
|
922
|
+
// Find the rightmost content word and boost its primary by 1 level.
|
|
923
|
+
for (let i = words.length - 1; i >= 0; i--) {
|
|
924
|
+
if (words[i].isContent) {
|
|
925
|
+
const word = words[i];
|
|
926
|
+
let maxIdx = -1;
|
|
927
|
+
let maxVal = -1;
|
|
928
|
+
for (let j = 0; j < word.syllables.length; j++) {
|
|
929
|
+
if (word.syllables[j].stress > maxVal) {
|
|
930
|
+
maxVal = word.syllables[j].stress;
|
|
931
|
+
maxIdx = j;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (maxIdx >= 0) {
|
|
935
|
+
// Boost the rightmost content word's primary by 1.
|
|
936
|
+
word.syllables[maxIdx].stress = word.syllables[maxIdx].stress + 1;
|
|
937
|
+
}
|
|
938
|
+
break; // Only the rightmost content word gets boosted.
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ─── RELATIVE STRESS ASSIGNMENT (4‑LEVEL) ─────────────────────────
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Convert numeric per‑syllable stress to McAleese’s symbolic levels
|
|
949
|
+
* (w, n, m, s) and resolve adjacent identical stresses using dependency
|
|
950
|
+
* information.
|
|
951
|
+
*/
|
|
952
|
+
export function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]): void {
|
|
953
|
+
// First pass: numeric → symbolic (0→w, 1→n, 2→m, 3+→s)
|
|
954
|
+
// Use lexicalStress (pre-nuclear) so nuclear stress doesn't corrupt meter detection.
|
|
955
|
+
for (const word of words) {
|
|
956
|
+
for (const syl of word.syllables) {
|
|
957
|
+
const val = syl.lexicalStress ?? syl.stress;
|
|
958
|
+
if (val === 0) {
|
|
959
|
+
// Zero-Provision (`x`) for a maximally-reduced clitic: a stressless
|
|
960
|
+
// syllable of a function word reads *below* a stressless content
|
|
961
|
+
// syllable (the/a/of/and… vs. the weak syllable of a content word).
|
|
962
|
+
// EXCEPTION: an aphaeresis clipping ('neath/o'er/'gainst…) is the
|
|
963
|
+
// *lexically-stressed* syllable of its base word surviving the clip — an
|
|
964
|
+
// overt syllable carrying real stress, merely reduced in context. `x`
|
|
965
|
+
// means extrametrical (Hayes' zero-provision), which it is NOT; so it
|
|
966
|
+
// floors at `w` (overt weak), promotable like any weak syllable.
|
|
967
|
+
const bare = word.word.toLowerCase().replace(/['’]/g, '');
|
|
968
|
+
// Function VERBS (copula/aux/aspectual: be/is/keeps/began…) and
|
|
969
|
+
// function ADVERBS (deictic/scalar: just/now/then/here/there…) floor
|
|
970
|
+
// at 'w', not 'x': both classes carry full, unreducible vowels —
|
|
971
|
+
// 'x' is for schwa-able clitics (the/a/of/and). At 'w' they remain
|
|
972
|
+
// Attridge-promotable, recovering e.g. the dactylic opening beat of
|
|
973
|
+
// "JUST for a riband to STICK in his coat" (Browning).
|
|
974
|
+
if (word.isContent || APHAERESIS_CLITICS.has(bare)
|
|
975
|
+
|| FUNCTION_VERBS.has(bare) || FUNCTION_ADVERBS.has(bare)) {
|
|
976
|
+
syl.relativeStress = 'w';
|
|
977
|
+
} else {
|
|
978
|
+
syl.relativeStress = 'x';
|
|
979
|
+
}
|
|
980
|
+
} else if (val === 1) {
|
|
981
|
+
syl.relativeStress = 'n';
|
|
982
|
+
} else if (val === 2) {
|
|
983
|
+
syl.relativeStress = 'm';
|
|
984
|
+
} else {
|
|
985
|
+
syl.relativeStress = 's';
|
|
986
|
+
}
|
|
987
|
+
// Monosyllabic function clitic → floor at 'w' (overt-weak, promotable),
|
|
988
|
+
// never 'n'. A CMU-primary monosyllabic preposition/determiner/possessive/
|
|
989
|
+
// wh-word/coordinator is reduced in running speech; flooring it at 'n' is
|
|
990
|
+
// what produced the flat function-word runs ("So on my", "where strange").
|
|
991
|
+
// (Pure clitics the/a/of already read 'x' via the val===0 branch.)
|
|
992
|
+
if (syl.relativeStress === 'n' && isMonosyllabicClitic(word)) {
|
|
993
|
+
syl.relativeStress = 'w';
|
|
994
|
+
}
|
|
995
|
+
// Downgrade extrametrical syllables by one level. We do NOT push a weak
|
|
996
|
+
// syllable to 'x' here: 'x' (zero-provision) is reserved for maximally-
|
|
997
|
+
// reduced *clitics*, whereas a weak *content* syllable (e.g. the feminine
|
|
998
|
+
// ending "li·cense") stays 'w' per the maintainer's tier semantics.
|
|
999
|
+
if (syl.extrametrical === 'morphological') {
|
|
1000
|
+
if (syl.relativeStress === 'n') syl.relativeStress = 'w';
|
|
1001
|
+
else if (syl.relativeStress === 'm') syl.relativeStress = 'n';
|
|
1002
|
+
else if (syl.relativeStress === 's') syl.relativeStress = 'm';
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Apply nuclear stress boosts to relative stress.
|
|
1008
|
+
// `syl.stress` may be higher than `syl.lexicalStress` after applyNuclearStress
|
|
1009
|
+
// boosted the rightmost content word. Each level of increase promotes the
|
|
1010
|
+
// relative stress by one tier: w→n, n→m, m→s.
|
|
1011
|
+
for (const word of words) {
|
|
1012
|
+
for (const syl of word.syllables) {
|
|
1013
|
+
const base = syl.lexicalStress ?? 0;
|
|
1014
|
+
const boost = syl.stress - base;
|
|
1015
|
+
if (boost > 0) {
|
|
1016
|
+
let current = syl.relativeStress ?? 'w';
|
|
1017
|
+
for (let i = 0; i < boost; i++) {
|
|
1018
|
+
if (current === 'x') current = 'w';
|
|
1019
|
+
else if (current === 'w') current = 'n';
|
|
1020
|
+
else if (current === 'n') current = 'm';
|
|
1021
|
+
else if (current === 'm') current = 's';
|
|
1022
|
+
}
|
|
1023
|
+
syl.relativeStress = current;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Second pass: resolve adjacent identical stresses within each phonological phrase
|
|
1029
|
+
for (const iu of ius) {
|
|
1030
|
+
for (const pp of iu.phonologicalPhrases) {
|
|
1031
|
+
const ppWords = collectPPTokens(pp);
|
|
1032
|
+
resolveAdjacentClashes(ppWords);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Third pass: resolve clashes across prosodic boundaries (PP and IU).
|
|
1037
|
+
// McAleese: when two adjacent syllables at a prosodic boundary have equal stress
|
|
1038
|
+
// and one is a function word, demote the function word (beginnings-free principle).
|
|
1039
|
+
resolveCrossBoundaryClashes(words, ius);
|
|
1040
|
+
|
|
1041
|
+
// Compound forestress (linear surface order): a left-stressed compound's
|
|
1042
|
+
// prominence sits on its LEFT element (WASTE·shore, SEA·shore, GHOST·town).
|
|
1043
|
+
// The phrasal compound/nuclear rules run in hierarchy order, which a mis-
|
|
1044
|
+
// grouped parse can split (e.g. "a cavernous waste shore" separating
|
|
1045
|
+
// waste/shore), so we re-assert forestress here on true surface adjacency,
|
|
1046
|
+
// after the clash passes, so it survives the rightmost-content nuclear boost.
|
|
1047
|
+
resolveCompoundForestress(words);
|
|
1048
|
+
resolveCollocationForestress(words);
|
|
1049
|
+
resolveHyphenCompounds(words);
|
|
1050
|
+
|
|
1051
|
+
// Fourth pass: resolve clashes on the LINEAR SURFACE order. A stress clash is
|
|
1052
|
+
// a property of *contiguous pronounced* syllables (Hayes' "two contiguous
|
|
1053
|
+
// syllables"), i.e. surface order — but the phrasal passes above run in
|
|
1054
|
+
// hierarchy order, which a mis-grouped parse can scramble (e.g. "a cavernous
|
|
1055
|
+
// waste shore" leaving "waste"/"shore" non-adjacent in the tree though
|
|
1056
|
+
// contiguous in speech). Catch any residual cardinal s–s clash here.
|
|
1057
|
+
resolveLinearClashes(words);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Ascending rank of the 5 relative-stress tiers, for level arithmetic. */
|
|
1061
|
+
const STRESS_RANK: Record<StressLevel, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
|
|
1062
|
+
const STRESS_LEVELS: StressLevel[] = ['x', 'w', 'n', 'm', 's'];
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Re-assert left-stress on forestressed compounds over the LINEAR surface
|
|
1066
|
+
* sequence (e.g. WASTE·shore, SEA·shore, GHOST·town, STORM·cloud). For each
|
|
1067
|
+
* pair of truly-adjacent content words (by absoluteIndex) that the Compound
|
|
1068
|
+
* Stress Rule marks left-stressed, the left element's peak is raised to the
|
|
1069
|
+
* pair's maximum prominence and the right element's peak is demoted one rung
|
|
1070
|
+
* below it — never raising the subordinate. Runs on surface order so it works
|
|
1071
|
+
* even when the parse mis-groups the two into different phrases.
|
|
1072
|
+
*/
|
|
1073
|
+
function resolveCompoundForestress(words: ClsWord[]): void {
|
|
1074
|
+
const content = words.filter(w => w.isContent && !isPunctuation(w.lexicalClass));
|
|
1075
|
+
for (let i = 0; i < content.length - 1; i++) {
|
|
1076
|
+
const w1 = content[i];
|
|
1077
|
+
const w2 = content[i + 1];
|
|
1078
|
+
if (Math.abs(w1.absoluteIndex - w2.absoluteIndex) !== 1) continue; // truly adjacent
|
|
1079
|
+
const pos1 = w1.lexicalClass, pos2 = w2.lexicalClass;
|
|
1080
|
+
if (!(pos2.startsWith('N') && (pos1.startsWith('N') || pos1.startsWith('J')))) continue;
|
|
1081
|
+
if (!isLeftStressedPair(w1.word, w2.word)) continue;
|
|
1082
|
+
|
|
1083
|
+
const s1 = wordPeak(w1);
|
|
1084
|
+
const s2 = wordPeak(w2);
|
|
1085
|
+
if (!s1 || !s2) continue;
|
|
1086
|
+
const r1 = STRESS_RANK[s1.relativeStress ?? 'w'];
|
|
1087
|
+
const r2 = STRESS_RANK[s2.relativeStress ?? 'w'];
|
|
1088
|
+
const hi = Math.max(r1, r2);
|
|
1089
|
+
s1.relativeStress = STRESS_LEVELS[hi]; // head ≥ both
|
|
1090
|
+
s2.relativeStress = STRESS_LEVELS[Math.min(r2, Math.max(0, hi - 1))]; // demote-only
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Forestress lexicalised collocations (GOOD old, END-all) over the LINEAR
|
|
1096
|
+
* surface sequence. Unlike `resolveCompoundForestress` this iterates ALL words
|
|
1097
|
+
* (not just content), because a collocation's second element may be a function
|
|
1098
|
+
* word ("end ALL" — "all" is a determiner): raise the left element's peak to the
|
|
1099
|
+
* pair maximum and demote the right one rung (demote-only, never raises the
|
|
1100
|
+
* subordinate).
|
|
1101
|
+
*/
|
|
1102
|
+
function resolveCollocationForestress(words: ClsWord[]): void {
|
|
1103
|
+
const seq = words
|
|
1104
|
+
.filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0)
|
|
1105
|
+
.sort((a, b) => a.absoluteIndex - b.absoluteIndex);
|
|
1106
|
+
for (let i = 0; i < seq.length - 1; i++) {
|
|
1107
|
+
const w1 = seq[i];
|
|
1108
|
+
const w2 = seq[i + 1];
|
|
1109
|
+
if (w2.absoluteIndex - w1.absoluteIndex !== 1) continue; // truly adjacent
|
|
1110
|
+
if (!isLeftStressedCollocation(w1, w2)) continue;
|
|
1111
|
+
|
|
1112
|
+
const s1 = wordPeak(w1);
|
|
1113
|
+
const s2 = wordPeak(w2);
|
|
1114
|
+
if (!s1 || !s2) continue;
|
|
1115
|
+
const r1 = STRESS_RANK[s1.relativeStress ?? 'w'];
|
|
1116
|
+
const r2 = STRESS_RANK[s2.relativeStress ?? 'w'];
|
|
1117
|
+
const hi = Math.max(r1, r2);
|
|
1118
|
+
s1.relativeStress = STRESS_LEVELS[hi]; // left element ≥ both
|
|
1119
|
+
s2.relativeStress = STRESS_LEVELS[Math.min(r2, Math.max(0, hi - 1))]; // demote-only
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Resolve the dual-strong clash at a hyphen seam inside a compound word
|
|
1125
|
+
* ("torch-flames", "blood-red"). The parser keeps a hyphenated compound as a
|
|
1126
|
+
* single token, so the word-level compound and clash passes never see its two
|
|
1127
|
+
* halves — left alone, both keep primary stress (s·s). For a hyphenated content
|
|
1128
|
+
* word whose hyphen parts align 1:1 with its syllables, an adjacent s·s seam is
|
|
1129
|
+
* resolved with the same logic as a two-word compound: forestress the left if it
|
|
1130
|
+
* is a known forestress modifier, otherwise retract the left (the nuclear /
|
|
1131
|
+
* right-stress default, e.g. torch-FLAMES).
|
|
1132
|
+
*/
|
|
1133
|
+
function resolveHyphenCompounds(words: ClsWord[]): void {
|
|
1134
|
+
for (const w of words) {
|
|
1135
|
+
if (!w.isContent || !w.word.includes('-')) continue;
|
|
1136
|
+
const parts = w.word.split('-').filter(p => p.length > 0);
|
|
1137
|
+
if (parts.length < 2 || parts.length !== w.syllables.length) continue;
|
|
1138
|
+
for (let i = 0; i < w.syllables.length - 1; i++) {
|
|
1139
|
+
const a = w.syllables[i];
|
|
1140
|
+
const b = w.syllables[i + 1];
|
|
1141
|
+
// An equal-strong seam (s·s or m·m) is the unresolved compound clash.
|
|
1142
|
+
const equalStrong = a.relativeStress === b.relativeStress
|
|
1143
|
+
&& (a.relativeStress === 's' || a.relativeStress === 'm');
|
|
1144
|
+
if (equalStrong) {
|
|
1145
|
+
if (isLeftStressedPair(parts[i], parts[i + 1])) demoteOneLevel(b); // BLOOD-red
|
|
1146
|
+
else demoteOneLevel(a); // torch-FLAMES
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Resolve cardinal stress clashes on the linear surface sequence: two
|
|
1154
|
+
* *contiguous* syllables both at the strongest level ('s') with no weaker
|
|
1155
|
+
* syllable between them. Per McAleese/Hayes, retract the LEFT stress one level
|
|
1156
|
+
* (s→m), with within-word-peak protection so a polysyllable's own peak is never
|
|
1157
|
+
* demoted for an adjacent monosyllable (the monosyllable yields instead).
|
|
1158
|
+
*
|
|
1159
|
+
* Deliberately limited to s–s (the cardinal clash the methodology resolves
|
|
1160
|
+
* unconditionally): m–m and lower are often metrically meaningful (spondaic /
|
|
1161
|
+
* emphatic substitutions) and are left for the meter layer to weigh.
|
|
1162
|
+
*/
|
|
1163
|
+
function resolveLinearClashes(words: ClsWord[]): void {
|
|
1164
|
+
const flat: { word: ClsWord; syl: Syllable }[] = [];
|
|
1165
|
+
for (const w of words) for (const s of w.syllables) flat.push({ word: w, syl: s });
|
|
1166
|
+
|
|
1167
|
+
for (let i = 0; i < flat.length - 1; i++) {
|
|
1168
|
+
const a = flat[i];
|
|
1169
|
+
const b = flat[i + 1];
|
|
1170
|
+
if (a.syl.relativeStress !== 's' || b.syl.relativeStress !== 's') continue;
|
|
1171
|
+
if (a.word === b.word) continue; // intra-word clashes → silent beat downstream
|
|
1172
|
+
|
|
1173
|
+
const aPeak = a.word.syllables.length > 1 && a.syl === wordPeak(a.word);
|
|
1174
|
+
const bPeak = b.word.syllables.length > 1 && b.syl === wordPeak(b.word);
|
|
1175
|
+
// Protect a polysyllable's peak: if only the left is a peak and the right is
|
|
1176
|
+
// a lone monosyllable, retract the monosyllable instead.
|
|
1177
|
+
if (aPeak && !bPeak && b.word.syllables.length === 1) {
|
|
1178
|
+
demoteOneLevel(b.syl);
|
|
1179
|
+
} else {
|
|
1180
|
+
demoteOneLevel(a.syl); // Hayes: retract the left stress
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Scan across the linear sequence of syllables and adjust any adjacent
|
|
1187
|
+
* identical relative stress levels using syntactic governance.
|
|
1188
|
+
*/
|
|
1189
|
+
function resolveAdjacentClashes(words: ClsWord[]): void {
|
|
1190
|
+
// "Endings strict": when a phrase ends in a run of two or more bare function
|
|
1191
|
+
// words (e.g. "…fast as you MIGHT"), the metrical beat gravitates to one of
|
|
1192
|
+
// them; the others are upbeat. Demote the others so a leftward governance
|
|
1193
|
+
// clash can't promote a medial off-beat ("you") over the phrase-final beat.
|
|
1194
|
+
// Phrases ending in a content word are untouched.
|
|
1195
|
+
{
|
|
1196
|
+
let runStart = words.length;
|
|
1197
|
+
while (runStart > 0 && !words[runStart - 1].isContent) runStart--;
|
|
1198
|
+
if (words.length - runStart >= 2) {
|
|
1199
|
+
// The beat is normally the run's last word, UNLESS that is a clause-final
|
|
1200
|
+
// oblique pronoun (me/him/them…), which is canonically unstressed — then
|
|
1201
|
+
// the beat falls on the preceding member ("and beHIND me", not "behind ME").
|
|
1202
|
+
let beatIdx = words.length - 1;
|
|
1203
|
+
if (OBLIQUE_PRONOUNS.has(words[beatIdx].word.toLowerCase()) && beatIdx > runStart) {
|
|
1204
|
+
beatIdx--;
|
|
1205
|
+
}
|
|
1206
|
+
for (let wi = runStart; wi < words.length; wi++) {
|
|
1207
|
+
if (wi === beatIdx) continue;
|
|
1208
|
+
const w = words[wi];
|
|
1209
|
+
const peak = wordPeak(w);
|
|
1210
|
+
for (const s of w.syllables) {
|
|
1211
|
+
// Protect a polysyllabic word's own lexical peak: never flatten a real
|
|
1212
|
+
// internal stress (be·HIND) to 'w' just because the word is functional.
|
|
1213
|
+
if (w.syllables.length > 1 && s === peak && (s.lexicalStress ?? s.stress) >= 1) continue;
|
|
1214
|
+
s.relativeStress = 'w';
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Flatten all syllables with reference to their owning word.
|
|
1221
|
+
const flat: { word: ClsWord; syl: Syllable }[] = [];
|
|
1222
|
+
for (const w of words) {
|
|
1223
|
+
for (const s of w.syllables) {
|
|
1224
|
+
flat.push({ word: w, syl: s });
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
for (let i = 0; i < flat.length - 1; i++) {
|
|
1229
|
+
const a = flat[i];
|
|
1230
|
+
const b = flat[i + 1];
|
|
1231
|
+
if (a.syl.relativeStress !== b.syl.relativeStress) continue;
|
|
1232
|
+
|
|
1233
|
+
// Within-word strictness (Kiparsky): a polysyllabic word's own stress peak
|
|
1234
|
+
// must not be demoted below its word-mates by a clash with an adjacent
|
|
1235
|
+
// monosyllable. Protect the peak; demote the monosyllable instead.
|
|
1236
|
+
const aPeak = a.word.syllables.length > 1 && a.syl === wordPeak(a.word);
|
|
1237
|
+
const bPeak = b.word.syllables.length > 1 && b.syl === wordPeak(b.word);
|
|
1238
|
+
if (aPeak && b.word.syllables.length === 1) {
|
|
1239
|
+
adjustAdjacent(a.syl, b.syl, governorDependentDirection);
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
if (bPeak && a.word.syllables.length === 1) {
|
|
1243
|
+
adjustAdjacent(b.syl, a.syl, governorDependentDirection);
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Otherwise use the syntactic governor relationship.
|
|
1248
|
+
const governor = getGovernor(a.word, b.word);
|
|
1249
|
+
if (governor === a.word) {
|
|
1250
|
+
// a governs b → a stronger, b weaker
|
|
1251
|
+
adjustAdjacent(a.syl, b.syl, governorDependentDirection);
|
|
1252
|
+
} else if (governor === b.word) {
|
|
1253
|
+
// b governs a → b stronger, a weaker
|
|
1254
|
+
adjustAdjacent(b.syl, a.syl, governorDependentDirection);
|
|
1255
|
+
}
|
|
1256
|
+
// If no relationship, leave untouched.
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** The syllable bearing a word's lexical stress peak (used for within-word protection). */
|
|
1261
|
+
function wordPeak(word: ClsWord): Syllable | undefined {
|
|
1262
|
+
let best: Syllable | undefined;
|
|
1263
|
+
let bestVal = -Infinity;
|
|
1264
|
+
for (const s of word.syllables) {
|
|
1265
|
+
const v = s.lexicalStress ?? s.stress;
|
|
1266
|
+
if (v > bestVal) { bestVal = v; best = s; }
|
|
1267
|
+
}
|
|
1268
|
+
return best;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** Return the governor word if one directly governs the other, else null. */
|
|
1272
|
+
function getGovernor(w1: ClsWord, w2: ClsWord): ClsWord | null {
|
|
1273
|
+
const dep1 = w1.dependency;
|
|
1274
|
+
const dep2 = w2.dependency;
|
|
1275
|
+
if (!dep1 || !dep2) return null;
|
|
1276
|
+
|
|
1277
|
+
// Check if w2 is a dependent of w1.
|
|
1278
|
+
if (dep2.governor === w1) return w1;
|
|
1279
|
+
// Check if w1 is a dependent of w2.
|
|
1280
|
+
if (dep1.governor === w2) return w2;
|
|
1281
|
+
return null;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/** Adjustment direction: governor stronger (promote), dependent weaker (demote). */
|
|
1285
|
+
function governorDependentDirection(gov: Syllable, dep: Syllable): void {
|
|
1286
|
+
const govStress = gov.relativeStress!;
|
|
1287
|
+
const depStress = dep.relativeStress!;
|
|
1288
|
+
|
|
1289
|
+
// Promote governor (if possible)
|
|
1290
|
+
if (govStress === 'n') gov.relativeStress = 'm';
|
|
1291
|
+
else if (govStress === 'm') gov.relativeStress = 's';
|
|
1292
|
+
// 'w' or 's' stay the same (can't promote 's', can't easily promote 'w' to 'n' without risking equal)
|
|
1293
|
+
|
|
1294
|
+
// Demote dependent (if possible)
|
|
1295
|
+
if (depStress === 's') dep.relativeStress = 'm';
|
|
1296
|
+
else if (depStress === 'm') dep.relativeStress = 'n';
|
|
1297
|
+
else if (depStress === 'n') dep.relativeStress = 'w';
|
|
1298
|
+
else if (depStress === 'w') dep.relativeStress = 'x';
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** Simple adjustment for two adjacent syllables. */
|
|
1302
|
+
function adjustAdjacent(
|
|
1303
|
+
stronger: Syllable,
|
|
1304
|
+
weaker: Syllable,
|
|
1305
|
+
direction: (s: Syllable, w: Syllable) => void
|
|
1306
|
+
): void {
|
|
1307
|
+
direction(stronger, weaker);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Demote a syllable's relative stress by one level: s→m, m→n, n→w, w→x, x stays x. */
|
|
1311
|
+
function demoteOneLevel(syl: Syllable): void {
|
|
1312
|
+
const cur = syl.relativeStress;
|
|
1313
|
+
if (cur === 's') syl.relativeStress = 'm';
|
|
1314
|
+
else if (cur === 'm') syl.relativeStress = 'n';
|
|
1315
|
+
else if (cur === 'n') syl.relativeStress = 'w';
|
|
1316
|
+
else if (cur === 'w') syl.relativeStress = 'x';
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Resolve stress clashes across prosodic boundaries (PP and IU).
|
|
1321
|
+
* When adjacent syllables at a boundary have equal stress:
|
|
1322
|
+
* - If one word is function and the other content, demote the function word
|
|
1323
|
+
* (per "beginnings free": the start of a new unit can be weakened)
|
|
1324
|
+
* - If both are same type, use dependency relationship
|
|
1325
|
+
* - If no relationship exists, leave untouched
|
|
1326
|
+
*/
|
|
1327
|
+
function resolveCrossBoundaryClashes(words: ClsWord[], ius: IntonationalUnit[]): void {
|
|
1328
|
+
// Build flat array with prosodic position tracking
|
|
1329
|
+
const flat: { word: ClsWord; syl: Syllable; ppKey: string }[] = [];
|
|
1330
|
+
for (let iuIdx = 0; iuIdx < ius.length; iuIdx++) {
|
|
1331
|
+
const iu = ius[iuIdx];
|
|
1332
|
+
for (let ppIdx = 0; ppIdx < iu.phonologicalPhrases.length; ppIdx++) {
|
|
1333
|
+
const pp = iu.phonologicalPhrases[ppIdx];
|
|
1334
|
+
const ppWords = collectPPTokens(pp);
|
|
1335
|
+
for (const w of ppWords) {
|
|
1336
|
+
for (const s of w.syllables) {
|
|
1337
|
+
flat.push({ word: w, syl: s, ppKey: `${iuIdx}:${ppIdx}` });
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
for (let i = 0; i < flat.length - 1; i++) {
|
|
1344
|
+
const a = flat[i];
|
|
1345
|
+
const b = flat[i + 1];
|
|
1346
|
+
if (a.syl.relativeStress !== b.syl.relativeStress) continue;
|
|
1347
|
+
|
|
1348
|
+
// Only adjust if they span a prosodic boundary
|
|
1349
|
+
if (a.ppKey === b.ppKey) continue;
|
|
1350
|
+
|
|
1351
|
+
const aContent = a.word.isContent;
|
|
1352
|
+
const bContent = b.word.isContent;
|
|
1353
|
+
|
|
1354
|
+
if (aContent && !bContent) {
|
|
1355
|
+
demoteOneLevel(b.syl);
|
|
1356
|
+
} else if (!aContent && bContent) {
|
|
1357
|
+
demoteOneLevel(a.syl);
|
|
1358
|
+
} else {
|
|
1359
|
+
// Both same content/function type — try dependency relationship
|
|
1360
|
+
const governor = getGovernor(a.word, b.word);
|
|
1361
|
+
if (governor === a.word) {
|
|
1362
|
+
adjustAdjacent(a.syl, b.syl, governorDependentDirection);
|
|
1363
|
+
} else if (governor === b.word) {
|
|
1364
|
+
adjustAdjacent(b.syl, a.syl, governorDependentDirection);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** Check whether a POS tag belongs to a content word category. */
|
|
1371
|
+
function isContentWord(tag: string, word?: string): boolean {
|
|
1372
|
+
if (CONTENT_POS.has(tag)) {
|
|
1373
|
+
if (word) {
|
|
1374
|
+
const lower = word.toLowerCase();
|
|
1375
|
+
if (FUNCTION_ADVERBS.has(lower)) return false;
|
|
1376
|
+
if (FUNCTION_VERBS.has(lower)) return false;
|
|
1377
|
+
}
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
return false;
|
|
1381
|
+
}
|