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"])