mirror of
https://github.com/kennethreitz/rhymepad.org.git
synced 2026-06-11 17:08:33 +00:00
Nasal coda class, go-live polish, mobile layout, hardening
- M/N/NG merge in consonance keys (damn/hand/plans, time/line); vc consensus lets vowel-founded groups advertise an agreed coda - Meta description, OG tags, orange favicon, theme-color - Mobile: single-column layout, panel stacks under the editor - /healthz, 100k-char draft cap, lookup length/limit caps - Internal fills brightened (19%, ends stay 34%) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
+24
-2
@@ -3,7 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RhymePad</title>
|
||||
<title>RhymePad — a scratchpad for poets & rappers</title>
|
||||
<meta name="description" content="Write lyrics and poems with live phonetic rhyme detection — internal rhymes, multisyllabics, multi-word mosaics — plus a dictionary, beats, and meter check.">
|
||||
<meta property="og:title" content="RhymePad">
|
||||
<meta property="og:description" content="A scratchpad that color-codes your rhyme schemes as you write. Real phonetic analysis: internal rhymes, slant rhymes, multi-word mosaics. Yes, it knows orange rhymes with door hinge.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://rhymepad.org">
|
||||
<meta name="theme-color" content="#14110f">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍊</text></svg>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,900&family=Spline+Sans+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
@@ -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%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -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 += `<span class="hseg" style="background:color-mix(in srgb, ${color} ${alpha}%, transparent);">${text}</span>`;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user