mirror of
https://github.com/kennethreitz/rhymepad.org.git
synced 2026-06-11 17:08:33 +00:00
Add per-line meter detection
CMU stress markers -> stress string per line, scored against the classical feet (iamb, trochee, anapest, dactyl, amphibrach) with flexible monosyllables. Syllable count + best-fit meter shown in the toolbar readout for the caret line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -247,6 +247,56 @@ def slant_key(word: str) -> str | None:
|
||||
return "gv:" + (m.group(0) if m else tail)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# meter
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
FEET = {
|
||||
"iambic": "01", "trochaic": "10", "anapestic": "001",
|
||||
"dactylic": "100", "amphibrachic": "010",
|
||||
}
|
||||
METER_NAMES = {1: "monometer", 2: "dimeter", 3: "trimeter", 4: "tetrameter",
|
||||
5: "pentameter", 6: "hexameter", 7: "heptameter", 8: "octameter"}
|
||||
|
||||
|
||||
def line_meter(line: str) -> dict | None:
|
||||
"""Syllable count and best-fit metrical foot for a line.
|
||||
|
||||
Stress comes from the CMU markers (1/2 stressed, 0 unstressed).
|
||||
Monosyllables flex in real speech, so function words read as
|
||||
unstressed and content monosyllables as wildcards.
|
||||
"""
|
||||
stress = ""
|
||||
for w in WORD_RE.findall(line):
|
||||
ph = phones_for(w)
|
||||
if not ph:
|
||||
stress += "x" # unknown word: one flexible syllable, at least
|
||||
continue
|
||||
syls = [p[-1] for p in ph.split() if p[-1].isdigit()]
|
||||
if len(syls) == 1:
|
||||
stress += "0" if w.lower() in STOPWORDS else "x"
|
||||
else:
|
||||
stress += "".join("1" if s in "12" else "0" for s in syls)
|
||||
n = len(stress)
|
||||
if n == 0:
|
||||
return None
|
||||
best_label, best_score = None, 0.0
|
||||
if n >= 4: # too short to call a meter
|
||||
for name, foot in FEET.items():
|
||||
pat = (foot * (n // len(foot) + 1))[:n] # final foot may truncate
|
||||
score = sum(a == "x" or a == b for a, b in zip(stress, pat)) / n
|
||||
if score > best_score:
|
||||
feet_count = round(n / len(foot))
|
||||
meter = METER_NAMES.get(feet_count, f"{feet_count}-foot")
|
||||
best_label, best_score = f"{name} {meter}", score
|
||||
return {
|
||||
"syl": n,
|
||||
"stress": stress,
|
||||
"label": best_label if best_score >= 0.75 else None,
|
||||
"score": round(best_score, 2),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# analysis
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -483,8 +533,16 @@ def analyze(draft: Draft):
|
||||
"slant": t["slant"] or groups_out[t["gid"]]["slant"]}
|
||||
for t in [*tokens, *phrases] if t["gid"] is not None
|
||||
]
|
||||
meter = []
|
||||
for i, line in enumerate(lines):
|
||||
if sids[i] is None:
|
||||
continue
|
||||
m = line_meter(line)
|
||||
if m:
|
||||
meter.append({"l": i, **m})
|
||||
|
||||
return {"lines": lines, "tokens": toks_out,
|
||||
"groups": groups_out, "stanzas": stanzas}
|
||||
"groups": groups_out, "stanzas": stanzas, "meter": meter}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
+6
-2
@@ -414,9 +414,13 @@ function buildReadout(){
|
||||
schemeReadout.innerHTML = '<span class="offline">backend offline — run: uv run uvicorn app:app</span>';
|
||||
return;
|
||||
}
|
||||
const parts = [];
|
||||
const ln = caretLine();
|
||||
const m = analysis && analysis.meter ? analysis.meter.find(x=>x.l===ln) : null;
|
||||
if(m) parts.push(`${m.syl} syl` + (m.label ? ` · ${m.label}` : ''));
|
||||
const st = caretStanza();
|
||||
if(!st || !st.scheme){ schemeReadout.innerHTML = ''; return; }
|
||||
schemeReadout.innerHTML = `stanza scheme <b>${st.scheme.toUpperCase().split('').join(' ')}</b>`;
|
||||
if(st && st.scheme) parts.push(`scheme <b>${st.scheme.toUpperCase().split('').join(' ')}</b>`);
|
||||
schemeReadout.innerHTML = parts.join(' ');
|
||||
}
|
||||
|
||||
function buildLegend(){
|
||||
|
||||
@@ -190,3 +190,28 @@ def test_stanzas_and_legend():
|
||||
def test_blank_lines_split_stanzas():
|
||||
res = analyze(Draft(text="the cat\na hat\n\nso blue\nso true"))
|
||||
assert [s["scheme"] for s in res["stanzas"]] == ["aa", "aa"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ meter
|
||||
|
||||
def meter_of(line: str) -> dict:
|
||||
res = analyze(Draft(text=line))
|
||||
return res["meter"][0]
|
||||
|
||||
|
||||
def test_iambic_pentameter():
|
||||
m = meter_of("Shall I compare thee to a summer's day")
|
||||
assert m["syl"] == 10
|
||||
assert m["label"] == "iambic pentameter"
|
||||
|
||||
|
||||
def test_trochaic_tetrameter():
|
||||
m = meter_of("Tyger Tyger burning bright")
|
||||
assert m["syl"] == 7 # catalectic — final unstressed syllable dropped
|
||||
assert m["label"] == "trochaic tetrameter"
|
||||
|
||||
|
||||
def test_syllables_always_reported():
|
||||
m = meter_of("the city hums in amber under fading light")
|
||||
assert m["syl"] == 12
|
||||
assert m["stress"]
|
||||
|
||||
Reference in New Issue
Block a user