diff --git a/app.py b/app.py index 069e5cc..b68b9e9 100644 --- a/app.py +++ b/app.py @@ -540,13 +540,31 @@ 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 = [] + meter, meter_by_line = [], {} for i, line in enumerate(lines): if sids[i] is None: continue m = line_meter(line) if m: - meter.append({"l": i, **m}) + entry = {"l": i, **m, "off": False} + meter.append(entry) + meter_by_line[i] = entry + + # meter coaching: when a stanza has a clear syllable pattern, flag + # the lines that break it + for s, lns in stanza_lines.items(): + entries = [meter_by_line[i] for i in lns if i in meter_by_line] + if len(entries) < 3: + continue + counts = [e["syl"] for e in entries] + # mode with +-1 tolerance: the count that covers the most lines + mode = max(set(counts), key=lambda c: sum(abs(v - c) <= 1 for v in counts)) + covered = sum(abs(v - mode) <= 1 for v in counts) + if covered / len(entries) >= 0.6: + for e in entries: + e["off"] = abs(e["syl"] - mode) > 1 + for e in entries: + e["target"] = mode return {"lines": lines, "tokens": toks_out, "groups": groups_out, "stanzas": stanzas, "meter": meter} diff --git a/static/index.html b/static/index.html index a91047b..92788d8 100644 --- a/static/index.html +++ b/static/index.html @@ -124,6 +124,11 @@ .rh.slant.end { box-shadow: inset 0 -2px 0 0 color-mix(in srgb, var(--c) 45%, transparent); } + .offbeat { + text-decoration: underline wavy color-mix(in srgb, var(--r6) 45%, transparent); + text-decoration-skip-ink: none; + text-underline-offset: 7px; + } .toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } @@ -494,7 +499,8 @@ function render(){ pos = t.e; }); h += esc(line.slice(pos)); - html += h + '\n'; + const m = fresh && analysis.meter ? analysis.meter.find(x=>x.l===i) : null; + html += (m && m.off ? `${h}` : h) + '\n'; }); highlight.innerHTML = html; highlight.scrollTop = editor.scrollTop; @@ -522,7 +528,11 @@ function buildReadout(){ 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}` : '')); + if(m){ + let p = `${m.syl} syl` + (m.label ? ` · ${m.label}` : ''); + if(m.off) p += ` · breaks stanza meter (~${m.target} syl)`; + parts.push(p); + } const st = caretStanza(); if(st && st.scheme) parts.push(`scheme ${st.scheme.toUpperCase().split('').join(' ')}`); schemeReadout.innerHTML = parts.join('    '); diff --git a/tests/test_rhymes.py b/tests/test_rhymes.py index 0768920..b77171d 100644 --- a/tests/test_rhymes.py +++ b/tests/test_rhymes.py @@ -222,3 +222,20 @@ def test_trailing_schwa_trimmed_militia_commissioner(): text = ("Swagger down pat, call my shit Patricia\n" "Young Money militia, and I am the commissioner") group_with(text, "patricia", "militia", "commissioner") + + +def test_meter_coaching_flags_outlier_line(): + text = ("I walk the lonely road tonight\n" + "I hold the heavy stone of light\n" + "I call the fading stars to fight\n" + "and everybody wonders where the time has gone") + res = analyze(Draft(text=text)) + flagged = [m["l"] for m in res["meter"] if m["off"]] + assert flagged == [3] + assert res["meter"][3]["target"] == 8 + + +def test_meter_coaching_needs_a_pattern_to_break(): + # two lines of wildly different lengths: no dominant pattern, no flags + res = analyze(Draft(text="short line here\na very much longer line that runs on and on")) + assert not any(m["off"] for m in res["meter"])