diff --git a/app.py b/app.py index 516e73a..2ab4744 100644 --- a/app.py +++ b/app.py @@ -282,6 +282,25 @@ def _grapheme_tail(raw: str) -> str | None: return tail +def rhyme_char_start(word: str) -> int: + """Best-guess character index where a word's rhyming tail begins: + take as many vowel-letter groups from the end as the pronunciation's + rhyming part has vowels (tonight -> 'ight', creation -> 'ation'). + Spelling can't map phonemes exactly, so this is an approximation.""" + ph = phones_for(word) + if not ph: + return 0 + nv = len(_tail_vowels(ph)) or 1 + groups = [m.start() for m in re.finditer(r"[aeiouyAEIOUY]+", word)] + # drop a silent trailing 'e' (write, time, fire) so it doesn't eat a slot + if (len(groups) > 1 and word[-1] in "eE" + and groups[-1] == len(word) - 1): + groups.pop() + if len(groups) < nv: + return 0 + return groups[len(groups) - nv] + + def rime_keys(word: str) -> tuple[str, ...]: """Perfect-rhyme keys, one per candidate pronunciation.""" cands = phones_candidates(word) @@ -1295,12 +1314,16 @@ def analyze(draft: Draft): "legend": legend, }) - toks_out = [ - {"l": t["line"], "s": t["start"], "e": t["end"], "g": t["gid"], - "end": t["is_end"], "ph": "vowels" in t, - "slant": t["slant"] or groups_out[t["gid"]]["slant"]} - for t in [*tokens, *phrases] if t["gid"] is not None - ] + toks_out = [] + for t in [*tokens, *phrases]: + if t["gid"] is None: + continue + d = {"l": t["line"], "s": t["start"], "e": t["end"], "g": t["gid"], + "end": t["is_end"], "ph": "vowels" in t, + "slant": t["slant"] or groups_out[t["gid"]]["slant"]} + if "vowels" not in t: # single word: where its rhyming tail starts + d["rs"] = t["start"] + rhyme_char_start(t["word"]) + toks_out.append(d) meter, meter_by_line = [], {} for i, line in enumerate(lines): if sids[i] is None: diff --git a/static/index.html b/static/index.html index 807e0f0..cca2b0b 100644 --- a/static/index.html +++ b/static/index.html @@ -609,7 +609,7 @@ function render(){ h = esc(line); }else{ const cuts = new Set([0, line.length]); - toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); }); + toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); if(t.rs != null) cuts.add(t.rs); }); als.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); }); opens.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); }); const pts = [...cuts].sort((a,b)=>a-b); @@ -622,25 +622,31 @@ function render(){ const al = als.find(t=>t.s <= a && b <= t.e); const op = opens.find(t=>t.s <= a && b <= t.e); if(!w && !p && !al && !op){ h += text; continue; } - // fills = tail sound (rhyme); underline = head sound (alliteration); + // whole word fills dimly; the rhyming part gets a bright underline. // gray = an ending still waiting for its answer let style = ''; + const shadows = []; if(w || p){ const t = (w && (focusGid === null || w.g === focusGid)) ? w : (p && (focusGid === null || p.g === focusGid)) ? p : (w || p); - let alpha = !t.ph ? (t.end ? 34 : 19) : (t.end ? 24 : 14); + let alpha = !t.ph ? (t.end ? 28 : 16) : (t.end ? 20 : 12); if(t.slant) alpha = Math.round(alpha * 0.62); // slant sits back - // the caret's family brightens; everything else stays itself if(focusGid !== null && t.g === focusGid){ alpha = Math.min(85, Math.round(alpha * 2.8)); style += 'color:#1a120c;font-weight:600;'; // dark ink on the lit chip } style += `background:color-mix(in srgb, ${colorOf(t)} ${alpha}%, transparent);`; + // bright underline on the rhyming tail (rs..e) of a word + if(w && (w.rs == null || a >= w.rs)){ + const u = w.slant ? 55 : 80; + shadows.push(`inset 0 -2px 0 0 color-mix(in srgb, ${colorOf(w)} ${u}%, transparent)`); + } }else if(op){ style += `background:color-mix(in srgb, var(--ink-dim) 13%, transparent);`; } - if(al) style += `box-shadow:inset 0 -2px 0 0 color-mix(in srgb, var(--r${al.g % COLORS}) 75%, transparent);`; + if(al) shadows.push(`inset 0 -2px 0 0 color-mix(in srgb, var(--r${al.g % COLORS}) 75%, transparent)`); + if(shadows.length) style += `box-shadow:${shadows.join(',')};`; h += `${text}`; } } @@ -798,11 +804,6 @@ function buildReadout(){ const bi = st.lines.indexOf(ln); if(bi >= 0) parts.unshift(`bar ${bi + 1}/${st.lines.length}`); } - if(st && st.scheme){ - const sch = st.scheme.toUpperCase(); - const shown = sch.slice(0, 16).split('').join(' ') + (sch.length > 16 ? ' …' : ''); - parts.push(`scheme ${shown}`); - } if(focusGid !== null && analysis){ const g = analysis.groups.find(x=>x.id === focusGid); const fam = [...new Set(analysis.tokens.filter(t=>t.g===focusGid) diff --git a/tests/test_rhymes.py b/tests/test_rhymes.py index fc53cff..cc13c3f 100644 --- a/tests/test_rhymes.py +++ b/tests/test_rhymes.py @@ -740,3 +740,9 @@ def test_no_matching_across_stanzas(): # but within each stanza they still rhyme assert "night" in s1 assert any({"bright", "sight"} <= s for s in bg.values()) + + +def test_rhyme_char_start(): + from app import rhyme_char_start + assert rhyme_char_start("tonight") == 4 # 'ight' + assert rhyme_char_start("write") == 2 # 'ite' (silent-e skipped)