diff --git a/app.py b/app.py index 5c29157..9bede60 100644 --- a/app.py +++ b/app.py @@ -14,7 +14,7 @@ from functools import lru_cache from pathlib import Path import pronouncing -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from wordfreq import zipf_frequency @@ -192,7 +192,7 @@ def founding_projections(key: str) -> dict[str, str]: if vowels: out["slant"] = "v:" + " ".join(vowels) if len(ph) > 1 and ph[1] not in ARPA_VOWELS: - out["vc"] = "c:" + ph[0] + " " + ph[1] + out["vc"] = "c:" + ph[0] + " " + _coda_class(ph[1]) else: out["vc"] = "c:" + ph[0] mk = _multi_key(vowels) @@ -262,6 +262,14 @@ def rime_keys(word: str) -> tuple[str, ...]: return ("g:" + tail,) if tail else () +NASALS = {"M", "N", "NG"} + + +def _coda_class(c: str) -> str: + """damn/hand/plans and time/line ride one nasal class in delivery.""" + return "N" if c in NASALS else c + + def vc_key(word: str) -> str | None: """Last stressed vowel + first coda consonant — a consonance-aware slant key, so bliss / whisps / exist all share IH S.""" @@ -279,7 +287,7 @@ def vc_key(word: str) -> str | None: return None key = DIGITS.sub("", tail[0]) if len(tail) > 1: - key += " " + tail[1] + key += " " + _coda_class(tail[1]) return "c:" + key @@ -294,7 +302,7 @@ def _m2_key(seq: list[str]) -> str | None: vi = ph.index(vowels[0]) if vi + 1 >= len(ph) or ph[vi + 1] in ARPA_VOWELS: return None # open syllable — no coda to lean on - return f"m2:{vowels[0]} {ph[vi + 1]} x" + return f"m2:{vowels[0]} {_coda_class(ph[vi + 1])} x" def multi_keys(word: str) -> tuple[str, ...]: @@ -395,8 +403,18 @@ class Draft(BaseModel): text: str +MAX_DRAFT = 100_000 # chars — far beyond any song, well short of abuse + + +@app.get("/healthz") +def healthz(): + return {"ok": True} + + @app.post("/api/analyze") def analyze(draft: Draft): + if len(draft.text) > MAX_DRAFT: + raise HTTPException(413, "draft too large") lines = draft.text.split("\n") # stanza ids (blank-line separated) @@ -556,15 +574,20 @@ def analyze(draft: Draft): if (mk.startswith("m:") and sum(v != "x" for v in mk[2:].split()) >= 2): out.setdefault((t["sid"], mk), gi) - elif kind == "multi2": + elif kind in ("multi2", "vc"): # vowel-only founding keys can't carry a coda, but if 2+ - # members agree on one (orange + pourage both AO-R-schwa), - # it's part of the group's sound and phrases may join on it + # members agree on one (orange + pourage both AO-R-schwa; + # hand + plans both AE-nasal), it's part of the group's + # sound and others may join on it counts: Counter = Counter() for t in g["toks"]: - for mk in set(multi_keys(t["word"])): - if mk.startswith("m2:"): - counts[mk] += 1 + if kind == "vc": + mks = [vc_key(t["word"])] if vc_key(t["word"]) else [] + else: + mks = [m for m in set(multi_keys(t["word"])) + if m.startswith("m2:")] + for mk in mks: + counts[mk] += 1 for mk, c in counts.items(): if c >= 2: for s in {t["sid"] for t in g["toks"]}: @@ -955,7 +978,8 @@ def _ranked(words, exclude: set[str], limit: int) -> list[dict]: @app.get("/api/lookup") def lookup(word: str, mode: str = "rhyme", limit: int = 60): - w = word.strip().lower() + w = word.strip().lower()[:64] + limit = min(limit, 200) if mode == "syn": sections = synonyms_for(w, limit) return {"word": w, "mode": mode, "known": bool(sections), diff --git a/static/index.html b/static/index.html index 79b99cd..f11867f 100644 --- a/static/index.html +++ b/static/index.html @@ -3,7 +3,14 @@ -RhymePad +RhymePad — a scratchpad for poets & rappers + + + + + + + @@ -231,6 +238,21 @@ footer { grid-column: 1 / -1; } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { background: #3a342d; border-radius: 6px; border: 2px solid var(--panel); } + + /* ---- mobile: stack the panel under the editor ---- */ + @media (max-width: 900px){ + .wrap { + grid-template-columns: 1fr; + height: auto; min-height: 100dvh; + padding: 10px; gap: 10px; + } + header { flex-direction: column; align-items: flex-start; gap: 2px; padding-bottom: 8px; } + .brand { font-size: 24px; } + .editor-shell { min-height: 55dvh; } + aside { min-height: 45dvh; } + .toolbar { gap: 8px; } + .scheme-readout { margin-left: 0; width: 100%; } + } @@ -466,7 +488,7 @@ function render(){ if(!w && !p){ h += text; continue; } // fills only: words paint their group color; phrase rhymes paint // the stretches between, so multi-word matches still read as units - const alpha = w ? (w.end ? 34 : 13) : (p.end ? 22 : 10); + const alpha = w ? (w.end ? 34 : 19) : (p.end ? 24 : 14); const color = w ? colorOf(w) : colorOf(p); h += `${text}`; } diff --git a/tests/test_rhymes.py b/tests/test_rhymes.py index 94b90d6..794ffdf 100644 --- a/tests/test_rhymes.py +++ b/tests/test_rhymes.py @@ -404,3 +404,19 @@ def test_near_vowel_neutralized_before_r(): group_with(text, "head", "bed") group_with(text, "loud", "crowd") assert scheme(text) == "aabb" + + +def test_nasal_codas_merge(): + # Em delivers damn/hand/plans as one sound; M/N/NG share a coda + # class — and the engine finds the even fuller mosaic + text = ("If you never gave a damn, raise your hand\n" + "'Cause I'm about to set trip, vacation plans") + group_with(text, "gave a damn", "vacation plans") + group_with(text, "hand", "plans") + + +def test_analyze_rejects_oversized_drafts(): + import pytest as _pytest + from fastapi import HTTPException + with _pytest.raises(HTTPException): + analyze(Draft(text="a" * 200_000))