From 7a1e0e295391663fec0556d7e65c372a14b626ce Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 7 Jun 2026 01:12:34 -0400 Subject: [PATCH] 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) --- app.py | 60 +++++++++++++++++++++++++++++++++++++++++++- static/index.html | 8 ++++-- tests/test_rhymes.py | 25 ++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 7cb3a97..ff9ce7f 100644 --- a/app.py +++ b/app.py @@ -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} # -------------------------------------------------------------------------- diff --git a/static/index.html b/static/index.html index 0a584a8..69ba062 100644 --- a/static/index.html +++ b/static/index.html @@ -414,9 +414,13 @@ function buildReadout(){ schemeReadout.innerHTML = 'backend offline — run: uv run uvicorn app:app'; 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  ${st.scheme.toUpperCase().split('').join(' ')}`; + if(st && st.scheme) parts.push(`scheme ${st.scheme.toUpperCase().split('').join(' ')}`); + schemeReadout.innerHTML = parts.join('    '); } function buildLegend(){ diff --git a/tests/test_rhymes.py b/tests/test_rhymes.py index 26e44c4..254b65d 100644 --- a/tests/test_rhymes.py +++ b/tests/test_rhymes.py @@ -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"]