Files
rhymepad.org/static/index.html
T
kennethreitz 1027302ab8 Rename the stress toggle to "rhythm"
Sheet music for rapping — the label should say what it feels like,
not what linguists call it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:10:39 -04:00

1037 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RhymePad — a scratchpad for poets &amp; rappers</title>
<meta name="description" content="Write lyrics and poems with live phonetic rhyme detection — internal rhymes, multisyllabics, multi-word mosaics — plus rhyme & synonym lookup, beats, and per-line meter.">
<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">
<style>
:root {
--bg: #14110f;
--panel: #1c1916;
--panel-2: #221e1a;
--ink: #f2e9dd;
--ink-dim: #a79a89;
--line: #322c26;
--accent: #e8814a;
--accent-2: #d4a843;
/* rhyme group colors — distinct, warm-leaning, readable on dark */
--r0:#e8814a; --r1:#4ea3e8; --r2:#6fd08c; --r3:#d46fb8;
--r4:#d4a843; --r5:#9b7ce8; --r6:#e85a5a; --r7:#46cabf;
--r8:#c0d44e; --r9:#e8a0c0; --r10:#7ad4d4; --r11:#d9824e;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background:
radial-gradient(1200px 600px at 85% -10%, rgba(232,129,74,0.10), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(78,163,232,0.07), transparent 55%),
var(--bg);
color: var(--ink);
font-family: 'Spline Sans Mono', monospace;
font-size: 15px;
-webkit-font-smoothing: antialiased;
}
.wrap {
display: grid;
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr auto;
gap: 14px;
height: 100vh;
padding: 16px;
}
header {
grid-column: 1 / -1;
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid var(--line);
padding-bottom: 10px;
}
.brand {
font-family: 'Fraunces', serif;
font-weight: 900;
font-size: 30px;
letter-spacing: -0.02em;
color: var(--ink);
}
.brand span { color: var(--accent); }
.tagline { color: var(--ink-dim); font-size: 12px; letter-spacing: 0.04em; }
/* ---- editor side ---- */
.editor-col { display: flex; flex-direction: column; min-height: 0; gap: 10px; }
.drafts-bar { display: flex; gap: 6px; overflow-x: auto; flex: none; padding-bottom: 2px; }
.dtab {
display: flex; align-items: center; gap: 8px;
font-size: 12px; padding: 6px 12px; max-width: 170px;
background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
color: var(--ink-dim); cursor: pointer; white-space: nowrap;
transition: all .15s; user-select: none; flex: none;
}
.dtab .dtitle { overflow: hidden; text-overflow: ellipsis; }
.dtab:hover { color: var(--ink); }
.dtab.active { color: var(--ink); border-color: var(--accent); background: rgba(232,129,74,0.08); }
.dtab .x { color: var(--ink-dim); font-size: 13px; line-height: 1; }
.dtab .x:hover { color: var(--r6); }
.dtab.new { color: var(--accent); font-weight: 600; }
.editor-shell {
position: relative;
flex: 1;
min-height: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
/* highlight layer + textarea share the exact same box metrics */
#highlight, #stresslayer, #editor {
position: absolute; inset: 0;
margin: 0; border: 0;
padding: 22px 24px;
font: inherit;
line-height: 2.35;
white-space: pre-wrap;
word-wrap: break-word;
overflow-y: auto;
letter-spacing: 0;
tab-size: 4;
}
#highlight {
pointer-events: none;
color: transparent;
z-index: 1;
}
#stresslayer {
pointer-events: none;
color: transparent;
z-index: 0;
}
.sw { position: relative; }
.sd {
position: absolute; left: 0; right: 0; top: 2.2em;
text-align: center; font-size: 8px; letter-spacing: 2px;
line-height: 1; color: var(--ink); white-space: nowrap;
}
#editor {
background: transparent;
color: var(--ink);
caret-color: var(--accent);
resize: none;
z-index: 2;
outline: none;
}
#editor::placeholder { color: #5a5249; }
/* colored rhyme segments — words tint the background, phrases draw the
underline; both can overlap in different colors. Background/shadow
only, so the textarea text on top stays crisp and box metrics stay
identical (no horizontal padding!) */
.hseg { border-radius: 4px; }
.toolbar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.btn {
font-family: inherit; font-size: 13px;
background: var(--panel-2);
color: var(--ink);
border: 1px solid var(--line);
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
transition: all .15s;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.primary { background: var(--accent); color: #1a120c; border-color: var(--accent); font-weight: 600; }
.btn.primary:hover { filter: brightness(1.08); color: #1a120c; }
.scheme-readout {
margin-left: auto; color: var(--ink-dim); font-size: 12px;
letter-spacing: 0.15em;
}
.scheme-readout b { color: var(--accent-2); letter-spacing: 0.15em; }
.scheme-readout .offline { color: var(--r6); letter-spacing: 0.02em; }
.mtoggle {
display: flex; align-items: center; gap: 6px;
color: var(--ink-dim); font-size: 12px; cursor: pointer; user-select: none;
}
.mtoggle input { accent-color: var(--accent); }
.mtoggle:hover { color: var(--ink); }
/* ---- side panel ---- */
aside {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
display: flex; flex-direction: column;
min-height: 0;
overflow: hidden;
}
.tabs { display: flex; border-bottom: 1px solid var(--line); }
.tab {
flex: 1; padding: 12px 0; text-align: center; cursor: pointer;
font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--ink-dim); background: transparent; border: 0;
font-family: inherit; transition: color .15s;
}
.tab.active { color: var(--accent); box-shadow: inset 0 -2px 0 var(--accent); }
.tab:hover { color: var(--ink); }
.panel-body { padding: 16px; overflow-y: auto; flex: 1; }
.lookup-row { display: flex; gap: 8px; }
.lookup-row input {
flex: 1; font-family: inherit; font-size: 14px;
background: var(--panel-2); border: 1px solid var(--line);
color: var(--ink); padding: 9px 12px; border-radius: 8px; outline: none;
}
.lookup-row input:focus { border-color: var(--accent); }
.seg { display: flex; gap: 4px; margin: 14px 0 10px; }
.seg button {
flex: 1; font-family: inherit; font-size: 11px; letter-spacing: .04em;
background: var(--panel-2); color: var(--ink-dim); border: 1px solid var(--line);
padding: 7px 0; border-radius: 7px; cursor: pointer; text-transform: uppercase;
}
.seg button.active { color: var(--accent); border-color: var(--accent); }
.results { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 6px; }
.defcard {
background: var(--panel-2); border: 1px solid var(--line);
border-radius: 10px; padding: 12px 14px; margin: 10px 0 4px;
}
.defhead { display: flex; align-items: baseline; gap: 8px; font-size: 14px; }
.defx { margin-left: auto; cursor: pointer; color: var(--ink-dim); font-size: 15px; }
.defx:hover { color: var(--r6); }
.defphon { font-size: 12px; margin: 8px 0 10px; line-height: 1.6; }
.defphon i { color: var(--accent-2); font-style: normal; }
.chip {
font-size: 13px; background: var(--panel-2); border: 1px solid var(--line);
color: var(--ink); padding: 5px 10px; border-radius: 999px; cursor: pointer;
transition: all .12s;
}
.chip:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); }
.chip.common { font-weight: 600; }
.chip.rare { color: var(--ink-dim); opacity: .75; }
.chip.near { border-style: dashed; color: var(--ink-dim); }
.chip.near:hover { color: var(--accent); }
.muted { color: var(--ink-dim); font-size: 13px; line-height: 1.6; }
.res-label { font-size:11px; text-transform:uppercase; letter-spacing:.1em; color:var(--ink-dim); margin:16px 0 4px; }
/* beats */
.beat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.beat {
background: var(--panel-2); border: 1px solid var(--line); border-radius: 9px;
padding: 12px; cursor: pointer; transition: all .15s;
}
.beat:hover { border-color: var(--accent); }
.beat.playing { border-color: var(--accent); background: rgba(232,129,74,0.1); }
.beat .name { font-weight: 600; font-size: 13px; }
.beat .bpm { color: var(--ink-dim); font-size: 11px; margin-top: 2px; }
.transport { margin-top: 16px; }
.tempo-row { display:flex; align-items:center; gap:10px; margin-top: 8px; }
.tempo-row input[type=range] { flex:1; accent-color: var(--accent); }
.tempo-val { font-variant-numeric: tabular-nums; color: var(--accent-2); min-width: 64px; text-align:right; font-size:13px;}
.beat-controls { display:flex; gap:8px; margin-top:12px; }
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; }
#highlight, #stresslayer, #editor { font-size: 16px; } /* sub-16px makes iOS zoom-jump */
aside { min-height: 45dvh; }
.toolbar { gap: 8px; }
.scheme-readout { margin-left: 0; width: 100%; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">Rhyme<span>Pad</span></div>
<div class="tagline">a scratchpad for poets &amp; rappers — real phonetic rhyme detection</div>
</header>
<!-- EDITOR -->
<div class="editor-col">
<div class="drafts-bar" id="draftsBar"></div>
<div class="editor-shell">
<div id="stresslayer"></div>
<div id="highlight"></div>
<textarea id="editor" spellcheck="false" placeholder="Start writing. Leave a blank line between stanzas.
Rhymes are detected phonetically — line endings get the underline,
internal rhymes get the soft glow, same color = same sound.
Double-click any word to look it up on the right."></textarea>
</div>
<div class="toolbar">
<button class="btn primary" id="copyBtn">Copy to clipboard</button>
<button class="btn" id="exportBtn" title="Download this draft as a color-coded PNG">Export image</button>
<label class="mtoggle" title="Sheet music for your flow — syllable emphasis dots under each word"><input type="checkbox" id="stressToggle"> rhythm</label>
<div class="scheme-readout" id="schemeReadout"></div>
</div>
</div>
<!-- PANEL -->
<aside>
<div class="tabs">
<button class="tab active" data-tab="lookup">Rhymes &amp; words</button>
<button class="tab" data-tab="beats">Beats</button>
</div>
<div class="panel-body" id="tab-lookup">
<div class="lookup-row">
<input id="lookupInput" placeholder="a word…" autocomplete="off">
<button class="btn" id="lookupBtn">Go</button>
</div>
<div class="seg" id="modeSeg">
<button class="active" data-mode="desc" title="Adjectives that describe it">Describes</button>
<button data-mode="rhyme">Rhymes</button>
<button data-mode="syn">Synonyms</button>
</div>
<div id="defBox"></div>
<div id="lookupResults">
<p class="muted">Type a word and hit Go, or double-click a word in your draft. A phonetic readout pins on top; the buttons switch rhymes, synonyms, and describing words. Tip: end a word with “?” to jump to synonyms.</p>
</div>
</div>
<div class="panel-body" id="tab-beats" style="display:none">
<div class="beat-grid" id="beatGrid"></div>
<div class="transport">
<div class="tempo-row">
<span class="muted">Tempo</span>
<input type="range" id="tempo" min="60" max="180" value="90">
<span class="tempo-val" id="tempoVal">90 BPM</span>
</div>
<div class="tempo-row">
<span class="muted">Volume</span>
<input type="range" id="vol" min="0" max="100" value="70">
<span class="tempo-val" id="volVal">70%</span>
</div>
<div class="beat-controls">
<button class="btn primary" id="playBeat">Play</button>
<button class="btn" id="stopBeat">Stop</button>
</div>
</div>
</div>
</aside>
<footer></footer>
</div>
<script>
/* ============================================================
ANALYSIS — real phonetic rhyme detection lives in the Python
backend (CMU pronouncing dictionary). We POST the draft and
get back colored token spans:
end rhymes -> underline + tint
internal -> soft tint
slant rhymes -> faded underline (shared vowel sound)
============================================================ */
const editor = document.getElementById('editor');
const highlight = document.getElementById('highlight');
const stresslayer = document.getElementById('stresslayer');
const stressToggle = document.getElementById('stressToggle');
stressToggle.checked = false;
stressToggle.addEventListener('change', render);
const schemeReadout = document.getElementById('schemeReadout');
const COLORS = 12;
function esc(s){ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }
let analysis = null; // last server response
let backendOk = true;
let analyzeSeq = 0; // guards against out-of-order responses
const SAMPLE_TEXT =
`I keep the cadence tucked beneath my tongue tonight
the city hums in amber under fading light
I trace a melody that never quite takes flight
and let the silence answer everything I write
I put my orange
four-inch
door hinge
in storage,
and ate porridge with George`;
/* ---------- drafts — multiple docs in localStorage, tabbed ---------- */
const DOCS_KEY = 'rhymepad.docs';
const docKey = id => 'rhymepad.doc.' + id;
const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
function loadDocs(){
try{
const s = JSON.parse(localStorage.getItem(DOCS_KEY));
if(s && s.docs && s.docs.length && s.docs.some(d=>d.id===s.current)) return s;
}catch(e){}
// migrate the single-draft era; a brand-new visitor starts with the
// sample so the colors are visible before they type a word
const id = newId();
const text = localStorage.getItem('rhymepad.draft') ?? SAMPLE_TEXT;
localStorage.setItem(docKey(id), text);
const s = {docs: [{id, title: titleOf(text)}], current: id};
localStorage.setItem(DOCS_KEY, JSON.stringify(s));
return s;
}
function titleOf(text){
const l = (text || '').split('\n').find(l=>l.trim());
if(!l) return 'Untitled';
const t = l.trim();
return t.length > 26 ? t.slice(0, 25) + '…' : t;
}
function saveDocs(){
try{ localStorage.setItem(DOCS_KEY, JSON.stringify(docsState)); }catch(e){}
}
let docsState = loadDocs();
editor.value = localStorage.getItem(docKey(docsState.current)) || '';
function persist(){
try{
localStorage.setItem(docKey(docsState.current), editor.value);
const doc = docsState.docs.find(d=>d.id===docsState.current);
const title = titleOf(editor.value);
if(doc && doc.title !== title){
doc.title = title;
const el = draftsBar.querySelector('.dtab.active .dtitle');
if(el) el.textContent = title;
}
document.title = (title && title !== 'Untitled')
? title + ' · RhymePad' : 'RhymePad — a scratchpad for poets & rappers';
saveDocs();
}catch(e){ /* storage full/blocked */ }
}
const draftsBar = document.getElementById('draftsBar');
function renderTabs(){
draftsBar.innerHTML = '';
docsState.docs.forEach(doc=>{
const tab = document.createElement('div');
tab.className = 'dtab' + (doc.id === docsState.current ? ' active' : '');
const title = document.createElement('span');
title.className = 'dtitle';
title.textContent = doc.title || 'Untitled';
tab.appendChild(title);
if(doc.id === docsState.current && docsState.docs.length > 1){
const x = document.createElement('span');
x.className = 'x'; x.textContent = '×'; x.title = 'Delete this draft';
x.addEventListener('click', e=>{ e.stopPropagation(); deleteDoc(doc.id); });
tab.appendChild(x);
}
tab.addEventListener('click', ()=>{ if(doc.id !== docsState.current) openDoc(doc.id); });
draftsBar.appendChild(tab);
});
const plus = document.createElement('div');
plus.className = 'dtab new'; plus.textContent = '+'; plus.title = 'New draft';
plus.addEventListener('click', newDoc);
draftsBar.appendChild(plus);
}
function openDoc(id){
// remember where we were in the draft we're leaving
const cur = docsState.docs.find(d=>d.id===docsState.current);
if(cur){ cur.sel = editor.selectionStart; cur.scroll = editor.scrollTop; }
docsState.current = id;
saveDocs();
editor.value = localStorage.getItem(docKey(id)) || '';
analysis = null;
renderTabs();
render(); analyze(); editor.focus();
const doc = docsState.docs.find(d=>d.id===id);
if(doc && doc.sel != null){
editor.setSelectionRange(doc.sel, doc.sel);
editor.scrollTop = doc.scroll || 0;
highlight.scrollTop = editor.scrollTop;
}
}
function newDoc(){
const id = newId();
docsState.docs.push({id, title: 'Untitled'});
localStorage.setItem(docKey(id), '');
openDoc(id);
}
function deleteDoc(id){
const doc = docsState.docs.find(d=>d.id===id);
const text = localStorage.getItem(docKey(id)) || '';
if(text.trim() && !confirm(`Delete “${doc.title}”?`)) return;
try{ if(text.trim()) localStorage.setItem('rhymepad.trash', text); }catch(e){}
localStorage.removeItem(docKey(id));
docsState.docs = docsState.docs.filter(d=>d.id!==id);
if(!docsState.docs.length){
const nid = newId();
docsState.docs = [{id: nid, title: 'Untitled'}];
localStorage.setItem(docKey(nid), '');
}
openDoc(docsState.docs[0].id);
}
renderTabs();
async function analyze(){
const text = editor.value;
const seq = ++analyzeSeq;
try{
const r = await fetch('/api/analyze', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({text})
});
if(seq !== analyzeSeq) return; // a newer request superseded us
analysis = await r.json();
backendOk = true;
}catch(e){
if(seq !== analyzeSeq) return;
backendOk = false;
}
render();
}
const analyzeSoon = debounce(analyze, 180);
function render(){
persist();
const lines = editor.value.split('\n');
const tokByLine = {};
const groupInfo = {};
if(analysis){
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
}
const colorOf = t => `var(--r${groupInfo[t.g].color % COLORS})`;
let html = '';
lines.forEach((line, i)=>{
// apply spans where the line still matches what the server analyzed;
// on an edited line, keep highlights over the unchanged prefix so
// typing at the end of a line doesn't blank it
const fresh = analysis && analysis.lines[i] === line;
let raw = [];
if(fresh){
raw = tokByLine[i] || [];
}else if(analysis && typeof analysis.lines[i] === 'string'){
const old = analysis.lines[i];
let cp = 0;
const n = Math.min(old.length, line.length);
while(cp < n && old[cp] === line[cp]) cp++;
raw = (tokByLine[i] || []).filter(t=>t.e <= cp);
}
const toks = raw.filter(t=>groupInfo[t.g]);
const words = toks.filter(t=>!t.ph);
const phrases = toks.filter(t=>t.ph);
let h = '';
if(!toks.length){
h = esc(line);
}else{
const cuts = new Set([0, line.length]);
toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
const pts = [...cuts].sort((a,b)=>a-b);
for(let k = 0; k < pts.length - 1; k++){
const a = pts[k], b = pts[k+1];
const text = esc(line.slice(a, b));
if(!text) continue;
const w = words.find(t=>t.s <= a && b <= t.e);
const p = phrases.find(t=>t.s <= a && b <= t.e);
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 : 14) : (p.end ? 24 : 10);
const color = w ? colorOf(w) : colorOf(p);
h += `<span class="hseg" style="background:color-mix(in srgb, ${color} ${alpha}%, transparent);">${text}</span>`;
}
}
html += h + '\n';
});
highlight.innerHTML = html;
renderStress(lines);
highlight.scrollTop = editor.scrollTop;
highlight.scrollLeft = editor.scrollLeft;
buildReadout();
}
function renderStress(lines){
if(!stressToggle.checked){ stresslayer.innerHTML = ''; return; }
const byLine = {};
if(analysis && analysis.stress) analysis.stress.forEach(s=>{ (byLine[s.l] ||= []).push(s); });
let html = '';
lines.forEach((line, i)=>{
const fresh = analysis && analysis.lines[i] === line;
const spans = (fresh ? (byLine[i] || []) : []).slice().sort((a,b)=>a.s-b.s);
let pos = 0, h2 = '';
spans.forEach(s=>{
if(s.s < pos) return;
h2 += esc(line.slice(pos, s.s));
const dots = [...s.st].map(c=> c === '0' ? '○' : '●').join('');
h2 += `<span class="sw">${esc(line.slice(s.s, s.e))}<span class="sd">${dots}</span></span>`;
pos = s.e;
});
h2 += esc(line.slice(pos));
html += h2 + '\n';
});
stresslayer.innerHTML = html;
stresslayer.scrollTop = editor.scrollTop;
stresslayer.scrollLeft = editor.scrollLeft;
}
function caretLine(){
return editor.value.slice(0, editor.selectionStart).split('\n').length - 1;
}
function caretStanza(){
if(!analysis || !analysis.stanzas.length) return null;
const ln = caretLine();
return analysis.stanzas.find(s=>s.lines.includes(ln))
|| analysis.stanzas.findLast(s=>s.lines[0] <= ln)
|| analysis.stanzas[0];
}
function buildReadout(){
if(!backendOk){
schemeReadout.innerHTML = '<span class="offline">backend offline — run: uv run uvicorn app:app</span>';
return;
}
const parts = [];
const ln = caretLine();
const m = analysis && analysis.meter ? analysis.meter.find(x=>x.l===ln) : null;
if(m){
let p = `${m.syl} syl` + (m.label ? ` · ${m.label}` : '');
parts.push(p);
}
const st = caretStanza();
if(st && st.scheme){
const sch = st.scheme.toUpperCase();
const shown = sch.slice(0, 16).split('').join(' ') + (sch.length > 16 ? ' …' : '');
parts.push(`scheme <b>${shown}</b>`);
}
schemeReadout.innerHTML = parts.join(' &nbsp;&nbsp; ');
}
editor.addEventListener('input', ()=>{ render(); analyzeSoon(); });
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = stresslayer.scrollTop = editor.scrollTop; highlight.scrollLeft = stresslayer.scrollLeft = editor.scrollLeft; });
editor.addEventListener('keyup', buildReadout);
editor.addEventListener('click', buildReadout);
/* ---------- double-click (or touch-select) a word -> look it up ---------- */
function lookupSelection(){
const sel = editor.value.slice(editor.selectionStart, editor.selectionEnd)
.trim().replace(/[^A-Za-z']/g,'');
if(sel && !sel.includes(' ')){
document.getElementById('lookupInput').value = sel;
tab('lookup');
setMode('desc');
}
}
editor.addEventListener('dblclick', lookupSelection);
if(window.matchMedia('(pointer: coarse)').matches){
// no double-click on touch — long-press word selection does the job
editor.addEventListener('select', debounce(()=>{
const len = editor.selectionEnd - editor.selectionStart;
if(len > 0 && len <= 30) lookupSelection();
}, 400));
}
/* ---------- insert at cursor ---------- */
/* ============================================================
LOOKUP — rhymes & near rhymes come from our backend (CMU dict,
frequency-ranked). Synonyms come from the free Datamuse API.
============================================================ */
const modeSeg = document.getElementById('modeSeg');
let mode = 'desc';
function setMode(m){
mode = m;
[...modeSeg.children].forEach(c=>c.classList.toggle('active', c.dataset.mode === m));
doLookup();
}
modeSeg.addEventListener('click', e=>{
const b = e.target.closest('button'); if(!b) return;
setMode(b.dataset.mode);
});
document.getElementById('lookupBtn').addEventListener('click', doLookup);
document.getElementById('lookupInput').addEventListener('keydown', e=>{ if(e.key==='Enter') doLookup(); });
const lookupInput = document.getElementById('lookupInput');
lookupInput.addEventListener('focus', ()=> lookupInput.select());
const resultsBox = document.getElementById('lookupResults');
async function doLookup(){
let word = document.getElementById('lookupInput').value.trim();
// RhymeZone-style: a trailing ? jumps to synonyms
if(word.endsWith('?')){
word = word.slice(0, -1).trim();
document.getElementById('lookupInput').value = word;
if(word) setMode('syn');
return;
}
if(!word) return;
document.getElementById('tab-lookup').scrollTop = 0;
// the card belongs to the word, not the submode: rebuild only when
// the word changes, and respect a dismissal until then
if(word.toLowerCase() !== defWord){
defWord = word.toLowerCase();
defDismissed = false;
showDefinition(word);
}
resultsBox.innerHTML = '<p class="muted">Searching…</p>';
try{
const r = await fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=${mode}`);
const data = await r.json();
if(mode === 'desc'){
renderDescribes(word);
return;
}
if(mode === 'syn'){
if(!data.known){
resultsBox.innerHTML = `<p class="muted">No synonyms found for “${esc(word)}”.</p>`;
return;
}
renderSections(word, data.sections);
}else{
if(!data.known){
resultsBox.innerHTML = `<p class="muted">“${esc(word)}” isn't in the pronunciation dictionary.</p>`;
return;
}
renderRhymes(word, data);
}
}catch(err){
resultsBox.innerHTML = `<p class="muted">Lookup failed — is the backend running? (${esc(String(err))})</p>`;
}
}
function rarity(z){
if(z == null) return '';
return z >= 4.6 ? ' common' : (z < 3.4 ? ' rare' : '');
}
function chipHtml(items, cls){
// items: strings or {word, z}
return '<div class="results">' +
items.map(d=>{
const w = d.word || d, r = rarity(d.z);
return `<span class="chip${cls ? ' ' + cls : ''}${r}" data-w="${esc(w)}">${esc(w)}</span>`;
}).join('') + '</div>';
}
function wireChips(){
// clicking a result navigates to that word's dictionary entry
resultsBox.querySelectorAll('.chip').forEach(c=>{
c.addEventListener('click', ()=>{
document.getElementById('lookupInput').value = c.dataset.w;
doLookup();
});
});
}
/* ---------- word card: the engine's own read on a word ---------- */
const defBox = document.getElementById('defBox');
let defWord = null, defDismissed = false;
function draftMates(word){
if(!analysis) return [];
const lw = word.toLowerCase();
const byG = {};
analysis.tokens.forEach(t=>{
if(t.ph) return;
const w = analysis.lines[t.l].slice(t.s, t.e).toLowerCase();
(byG[t.g] ||= new Set()).add(w);
});
const mates = new Set();
Object.values(byG).forEach(set=>{
if(set.has(lw)) set.forEach(w=>{ if(w !== lw) mates.add(w); });
});
return [...mates].slice(0, 8);
}
async function showDefinition(word){
defBox.innerHTML = `<div class="defcard"><div class="defhead"><b>${esc(word)}</b>` +
`<span class="defx" title="Dismiss">×</span></div>` +
`<div class="muted defphon" id="defPhon"></div></div>`;
defBox.querySelector('.defx').addEventListener('click', ()=>{ defBox.innerHTML=''; defDismissed = true; });
try{
const info = await (await fetch(`/api/word?word=${encodeURIComponent(word)}`)).json();
const el = defBox.querySelector('#defPhon');
if(!el) return;
if(!info.known){ el.textContent = "not in the pronunciation dictionary"; return; }
const dots = [...info.stress].map(s=>s==='1' ? '●' : '○').join('');
const mates = draftMates(word);
el.innerHTML = `/${esc(info.phones.toLowerCase())}/ · ${info.syl} syl ${dots}` +
` · rhymes on <i>${esc(info.rime.toLowerCase())}</i>` +
((info.homophones || []).length ? `<br>sounds like: ${info.homophones.map(esc).join(', ')}` : '') +
(mates.length ? `<br>in your draft: ${mates.map(esc).join(', ')}` : '');
}catch(e){}
}
async function renderDescribes(word){
resultsBox.innerHTML = '<p class="muted">Searching…</p>';
try{
const r = await fetch(`https://api.datamuse.com/words?rel_jjb=${encodeURIComponent(word)}&max=60`);
const data = await r.json();
const words = data.map(d=>d.word);
if(!words.length){
resultsBox.innerHTML = `<p class="muted">No describing words for “${esc(word)}”.</p>`;
return;
}
resultsBox.innerHTML = `<div class="res-label">words that describe “${esc(word)}”</div>` + chipHtml(words);
wireChips();
}catch(e){
resultsBox.innerHTML = '<p class="muted">Couldn\'t reach the describing-words service.</p>';
}
}
function renderChips(label, words){
if(!words.length){ resultsBox.innerHTML = '<p class="muted">No results.</p>'; return; }
resultsBox.innerHTML = `<div class="res-label">${label}</div>` + chipHtml(words.slice(0,50));
wireChips();
}
function renderSections(word, sections){
if(!sections.length){ resultsBox.innerHTML = `<p class="muted">No synonyms for “${esc(word)}”.</p>`; return; }
let h = '';
sections.forEach(s=>{
h += `<div class="res-label">${esc(s.label)}</div>` +
chipHtml(s.words.map(d=>d.word), s.label === 'synonyms' ? '' : 'near');
});
resultsBox.innerHTML = h;
wireChips();
}
function syllableSections(items, cls){
const bySyl = {};
items.forEach(d=>{ (bySyl[d.syl || 0] ||= []).push(d); });
return Object.keys(bySyl).sort((a,b)=>a-b).map(k=>
`<div class="res-label">${k == 0 ? '?' : k} syllable${k == 1 ? '' : 's'}</div>` + chipHtml(bySyl[k], cls)
).join('');
}
function renderRhymes(word, data){
const near = data.near || [];
if(!data.words.length && !near.length){
resultsBox.innerHTML = `<p class="muted">No rhymes for “${esc(word)}”.</p>`;
return;
}
let h = '';
if(data.words.length){
h += `<div class="res-label">Rhymes for “${esc(word)}”</div>` + syllableSections(data.words);
}
if(near.length){
h += `<div class="res-label">Near rhymes</div>` + chipHtml(near.map(d=>d.word), 'near');
}
resultsBox.innerHTML = h;
wireChips();
}
/* ============================================================
EXPORT IMAGE — draw the draft with its rhyme colors to a PNG,
entirely client-side.
============================================================ */
document.getElementById('exportBtn').addEventListener('click', async ()=>{
if(!editor.value.trim()) return;
await document.fonts.ready;
const lines = editor.value.split('\n');
const css = getComputedStyle(document.documentElement);
const palette = Array.from({length: COLORS}, (_, i)=>css.getPropertyValue(`--r${i}`).trim());
const ink = css.getPropertyValue('--ink').trim();
const bg = css.getPropertyValue('--bg').trim();
const S = 2, FS = 16, LH = FS * 1.9, PAD = 40;
const font = FS + "px 'Spline Sans Mono', monospace";
const probe = document.createElement('canvas').getContext('2d');
probe.font = font;
const w = Math.ceil(Math.max(220, ...lines.map(l=>probe.measureText(l).width)) + PAD * 2);
const h = Math.ceil(lines.length * LH + PAD * 2 + 18);
const canvas = document.createElement('canvas');
canvas.width = w * S; canvas.height = h * S;
const x = canvas.getContext('2d');
x.scale(S, S);
x.fillStyle = bg; x.fillRect(0, 0, w, h);
x.font = font; x.textBaseline = 'middle';
const groupInfo = {}, tokByLine = {};
if(analysis){
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
}
lines.forEach((line, i)=>{
const y = PAD + i * LH + LH / 2;
const fresh = analysis && analysis.lines[i] === line;
const toks = (fresh ? (tokByLine[i] || []) : []).filter(t=>groupInfo[t.g]);
const words = toks.filter(t=>!t.ph), phrases = toks.filter(t=>t.ph);
const cuts = new Set([0, line.length]);
toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
const pts = [...cuts].sort((a,b)=>a-b);
for(let k = 0; k < pts.length - 1; k++){
const a = pts[k], b = pts[k+1];
const wt = words.find(t=>t.s <= a && b <= t.e);
const pt = phrases.find(t=>t.s <= a && b <= t.e);
if(!wt && !pt) continue;
const t = wt || pt;
x.globalAlpha = wt ? (wt.end ? 0.34 : 0.14) : (pt.end ? 0.24 : 0.10);
x.fillStyle = palette[groupInfo[t.g].color % COLORS];
const x0 = PAD + x.measureText(line.slice(0, a)).width;
const wpx = x.measureText(line.slice(a, b)).width;
x.beginPath();
x.roundRect(x0 - 2, y - FS * 0.72, wpx + 4, FS * 1.42, 4);
x.fill();
x.globalAlpha = 1;
}
x.fillStyle = ink;
x.fillText(line, PAD, y);
});
x.fillStyle = 'rgba(167,154,137,0.55)';
x.font = "11px 'Spline Sans Mono', monospace";
x.textAlign = 'right';
x.fillText('rhymepad.org', w - 16, h - 16);
canvas.toBlob(blob=>{
const doc = docsState.docs.find(d=>d.id===docsState.current);
const name = ((doc && doc.title && doc.title !== 'Untitled') ? doc.title : 'rhymepad')
.replace(/[^\w\- ]+/g, '').trim() || 'rhymepad';
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name + '.png';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href), 5000);
flash('exportBtn', 'Exported \u2713');
});
});
/* ============================================================
COPY / CLEAR / SAMPLE
============================================================ */
document.getElementById('copyBtn').addEventListener('click', async ()=>{
try{ await navigator.clipboard.writeText(editor.value); flash('copyBtn','Copied!'); }
catch{ editor.select(); document.execCommand('copy'); flash('copyBtn','Copied!'); }
});
function flash(id,msg){
const b=document.getElementById(id); const o=b.textContent; b.textContent=msg;
setTimeout(()=>b.textContent=o,1100);
}
/* ============================================================
BEATS — Web Audio synthesized drum patterns, adjustable tempo.
No external samples; everything is generated.
============================================================ */
const tab = (name)=>{
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active', t.dataset.tab===name));
document.getElementById('tab-lookup').style.display = name==='lookup'?'block':'none';
document.getElementById('tab-beats').style.display = name==='beats'?'block':'none';
};
document.querySelectorAll('.tab').forEach(t=> t.addEventListener('click', ()=>tab(t.dataset.tab)));
const PATTERNS = {
'Boom Bap': {bpm:90, kick:[0,6,8], snare:[4,12], hat:[0,2,4,6,8,10,12,14], swing:0.12},
'Trap': {bpm:140, kick:[0,3,7,8,11], snare:[8], hat:[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], swing:0},
'Lo-Fi': {bpm:75, kick:[0,8], snare:[4,12], hat:[2,6,10,14], swing:0.18},
'Drill': {bpm:142, kick:[0,3,6,10,11], snare:[4,12,13], hat:[0,2,4,6,8,10,12,14], swing:0.05},
'Spoken/Jazz':{bpm:96, kick:[0,10], snare:[4,12], hat:[0,3,6,9,12,15], swing:0.2},
'West Coast': {bpm:94, kick:[0,6,8,14], snare:[4,12], hat:[0,2,4,6,8,10,12,14], swing:0.08}
};
let actx=null, masterGain=null, playing=false, schedTimer=null, current='Boom Bap', step=0, nextTime=0;
let userTempo = 90;
const beatGrid = document.getElementById('beatGrid');
Object.entries(PATTERNS).forEach(([name,p])=>{
const d=document.createElement('div'); d.className='beat'; d.dataset.name=name;
d.innerHTML = `<div class="name">${name}</div><div class="bpm">${p.bpm} BPM feel</div>`;
d.addEventListener('click', ()=>{
current=name;
document.getElementById('tempo').value = p.bpm;
setTempo(p.bpm);
if(!playing) startBeat();
document.querySelectorAll('.beat').forEach(b=>b.classList.toggle('playing', b.dataset.name===name));
});
beatGrid.appendChild(d);
});
const tempoEl = document.getElementById('tempo'), tempoVal=document.getElementById('tempoVal');
function setTempo(v){ userTempo=+v; tempoEl.value=v; tempoVal.textContent = v+' BPM'; }
tempoEl.addEventListener('input', e=> setTempo(e.target.value));
function ensureCtx(){
if(!actx){
actx = new (window.AudioContext||window.webkitAudioContext)();
masterGain = actx.createGain();
masterGain.connect(actx.destination);
setVol(volEl.value);
}
}
const volEl = document.getElementById('vol'), volVal = document.getElementById('volVal');
function setVol(v){
volVal.textContent = v + '%';
if(masterGain) masterGain.gain.value = Math.pow(v / 100, 2); // perceptual
}
volEl.addEventListener('input', e=> setVol(e.target.value));
function kick(t){
const o=actx.createOscillator(), g=actx.createGain();
o.frequency.setValueAtTime(150,t); o.frequency.exponentialRampToValueAtTime(50,t+0.12);
g.gain.setValueAtTime(0.9,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.18);
o.connect(g).connect(masterGain); o.start(t); o.stop(t+0.2);
}
function snare(t){
const noise=actx.createBufferSource();
const buf=actx.createBuffer(1, actx.sampleRate*0.2, actx.sampleRate);
const d=buf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
noise.buffer=buf;
const f=actx.createBiquadFilter(); f.type='highpass'; f.frequency.value=1500;
const g=actx.createGain(); g.gain.setValueAtTime(0.6,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.18);
noise.connect(f).connect(g).connect(masterGain); noise.start(t); noise.stop(t+0.2);
}
function hat(t){
const noise=actx.createBufferSource();
const buf=actx.createBuffer(1, actx.sampleRate*0.05, actx.sampleRate);
const d=buf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
noise.buffer=buf;
const f=actx.createBiquadFilter(); f.type='highpass'; f.frequency.value=7000;
const g=actx.createGain(); g.gain.setValueAtTime(0.3,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.05);
noise.connect(f).connect(g).connect(masterGain); noise.start(t); noise.stop(t+0.06);
}
function scheduler(){
const p=PATTERNS[current];
const secPer16 = (60/userTempo)/4;
while(nextTime < actx.currentTime + 0.1){
const s = step % 16;
const swing = (s%2===1) ? secPer16 * (p.swing||0) : 0;
const t = nextTime + swing;
if(p.kick.includes(s)) kick(t);
if(p.snare.includes(s)) snare(t);
if(p.hat.includes(s)) hat(t);
nextTime += secPer16;
step++;
}
schedTimer = setTimeout(scheduler, 25);
}
function startBeat(){
ensureCtx(); if(actx.state==='suspended') actx.resume();
if(playing) return;
playing=true; step=0; nextTime=actx.currentTime+0.05; scheduler();
document.querySelectorAll('.beat').forEach(b=>b.classList.toggle('playing', b.dataset.name===current));
document.getElementById('playBeat').textContent='Playing…';
}
function stopBeat(){
playing=false; clearTimeout(schedTimer);
document.querySelectorAll('.beat').forEach(b=>b.classList.remove('playing'));
document.getElementById('playBeat').textContent='Play';
}
document.getElementById('playBeat').addEventListener('click', startBeat);
document.getElementById('stopBeat').addEventListener('click', stopBeat);
document.addEventListener('keydown', e=>{
if(!(e.metaKey || e.ctrlKey)) return;
if(e.key === 's'){
e.preventDefault(); // it's already saved — say so
const prev = schemeReadout.innerHTML;
schemeReadout.innerHTML = 'saved ✓';
setTimeout(buildReadout, 900);
}else if(e.key === 'k'){
e.preventDefault();
tab('lookup');
lookupInput.focus();
}
});
if(window.matchMedia('(pointer: fine)').matches) editor.focus();
render();
analyze();
</script>
</body>
</html>