mirror of
https://github.com/kennethreitz/rhymepad.org.git
synced 2026-06-11 17:08:33 +00:00
84bae60dff
FastAPI backend with CMU-dict rhyme detection (perfect, internal, multisyllabic slant, consonance, multi-word phrases), g2p fallback for unknown words, rhyme/synonym lookup, draggable stanza reordering, localStorage drafts, and synthesized beats. 19 regression tests drawn from real verses (Wayne, Em, Kanye). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
734 lines
29 KiB
HTML
734 lines
29 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>RhymePad</title>
|
|
<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; }
|
|
.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, #editor {
|
|
position: absolute; inset: 0;
|
|
margin: 0; border: 0;
|
|
padding: 22px 24px;
|
|
font: inherit;
|
|
line-height: 1.9;
|
|
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;
|
|
}
|
|
#editor {
|
|
background: transparent;
|
|
color: var(--ink);
|
|
caret-color: var(--accent);
|
|
resize: none;
|
|
z-index: 2;
|
|
outline: none;
|
|
}
|
|
#editor::placeholder { color: #5a5249; }
|
|
/* colored rhyme tokens — background tint only, so the textarea text on
|
|
top stays crisp and box metrics stay identical (no horizontal padding!) */
|
|
.rh {
|
|
border-radius: 4px;
|
|
background: color-mix(in srgb, var(--c) 15%, transparent);
|
|
}
|
|
.rh.end {
|
|
background: color-mix(in srgb, var(--c) 24%, transparent);
|
|
box-shadow: inset 0 -2px 0 0 var(--c);
|
|
}
|
|
.rh.slant.end {
|
|
box-shadow: inset 0 -2px 0 0 color-mix(in srgb, var(--c) 45%, transparent);
|
|
}
|
|
.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; }
|
|
|
|
/* ---- stanza reorder overlay ---- */
|
|
#reorderOverlay {
|
|
position: fixed; inset: 0; z-index: 50;
|
|
background: rgba(10,8,6,0.7); backdrop-filter: blur(3px);
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
#reorderOverlay[hidden] { display: none; }
|
|
.reorder-box {
|
|
width: min(560px, 92vw); max-height: 80vh;
|
|
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
|
display: flex; flex-direction: column; overflow: hidden;
|
|
}
|
|
.reorder-box h3 {
|
|
margin: 0; padding: 16px 20px 12px;
|
|
font-family: 'Fraunces', serif; font-weight: 600; font-size: 18px;
|
|
}
|
|
.reorder-box .hint { padding: 0 20px 10px; color: var(--ink-dim); font-size: 12px; }
|
|
#stanzaList { padding: 0 16px 8px; overflow-y: auto; flex: 1; }
|
|
.stanza-card {
|
|
display: flex; align-items: center; gap: 12px;
|
|
background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px;
|
|
padding: 12px 14px; margin-bottom: 8px; cursor: grab;
|
|
transition: border-color .15s, opacity .15s;
|
|
}
|
|
.stanza-card:active { cursor: grabbing; }
|
|
.stanza-card.dragging { opacity: 0.35; }
|
|
.stanza-card.drop-above { border-top: 2px solid var(--accent); }
|
|
.stanza-card.drop-below { border-bottom: 2px solid var(--accent); }
|
|
.stanza-card .grip { color: var(--ink-dim); font-size: 16px; user-select: none; }
|
|
.stanza-card .preview { flex: 1; min-width: 0; }
|
|
.stanza-card .first-line {
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px;
|
|
}
|
|
.stanza-card .meta-line { color: var(--ink-dim); font-size: 11px; margin-top: 3px; }
|
|
.stanza-card .scheme-badge {
|
|
color: var(--accent-2); font-size: 11px; letter-spacing: 0.18em;
|
|
text-transform: uppercase; white-space: nowrap;
|
|
}
|
|
.reorder-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; }
|
|
|
|
/* ---- 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; }
|
|
.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); }
|
|
.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; }
|
|
.legend { margin-top: 14px; }
|
|
.legend-item { display:flex; align-items:center; gap:8px; font-size:12px; margin:5px 0; color: var(--ink-dim); }
|
|
.swatch { width:14px; height:14px; border-radius:4px; }
|
|
footer { grid-column: 1 / -1; }
|
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
::-webkit-scrollbar-thumb { background: #3a342d; border-radius: 6px; border: 2px solid var(--panel); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header>
|
|
<div class="brand">Rhyme<span>Pad</span></div>
|
|
<div class="tagline">a scratchpad for poets & rappers — real phonetic rhyme detection</div>
|
|
</header>
|
|
|
|
<!-- EDITOR -->
|
|
<div class="editor-col">
|
|
<div class="editor-shell">
|
|
<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="reorderBtn">Reorder stanzas</button>
|
|
<button class="btn" id="clearBtn">Clear</button>
|
|
<button class="btn" id="sampleBtn">Load sample</button>
|
|
<div class="scheme-readout" id="schemeReadout"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PANEL -->
|
|
<aside>
|
|
<div class="tabs">
|
|
<button class="tab active" data-tab="lookup">Rhymes & Synonyms</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="rhyme">Rhymes</button>
|
|
<button data-mode="near">Near rhymes</button>
|
|
<button data-mode="syn">Synonyms</button>
|
|
</div>
|
|
<div id="lookupResults">
|
|
<p class="muted">Type a word and hit Go, or double-click a word in your draft. Click any result to insert it at the cursor.</p>
|
|
</div>
|
|
<div class="legend">
|
|
<div class="res-label">Current stanza</div>
|
|
<div id="legendBox"><p class="muted">No rhymes detected yet.</p></div>
|
|
</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="beat-controls">
|
|
<button class="btn primary" id="playBeat">Play</button>
|
|
<button class="btn" id="stopBeat">Stop</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<footer></footer>
|
|
</div>
|
|
|
|
<!-- STANZA REORDER -->
|
|
<div id="reorderOverlay" hidden>
|
|
<div class="reorder-box">
|
|
<h3>Reorder stanzas</h3>
|
|
<div class="hint">Drag cards to rearrange. Changes apply instantly.</div>
|
|
<div id="stanzaList"></div>
|
|
<div class="reorder-actions">
|
|
<button class="btn primary" id="reorderDone">Done</button>
|
|
</div>
|
|
</div>
|
|
</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 schemeReadout = document.getElementById('schemeReadout');
|
|
const COLORS = 12;
|
|
|
|
function esc(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
function debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }
|
|
|
|
let analysis = null; // last server response
|
|
let backendOk = true;
|
|
|
|
/* ---------- draft persistence — nothing gets lost on a stray refresh ---------- */
|
|
const DRAFT_KEY = 'rhymepad.draft';
|
|
editor.value = localStorage.getItem(DRAFT_KEY) || '';
|
|
function persist(){
|
|
try{ localStorage.setItem(DRAFT_KEY, editor.value); }catch(e){ /* storage full/blocked */ }
|
|
}
|
|
|
|
async function analyze(){
|
|
const text = editor.value;
|
|
try{
|
|
const r = await fetch('/api/analyze', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({text})
|
|
});
|
|
analysis = await r.json();
|
|
backendOk = true;
|
|
}catch(e){
|
|
backendOk = false;
|
|
}
|
|
render();
|
|
}
|
|
const analyzeSoon = debounce(analyze, 250);
|
|
|
|
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); });
|
|
}
|
|
let html = '';
|
|
lines.forEach((line, i)=>{
|
|
// only apply spans if this line still matches what the server analyzed
|
|
const fresh = analysis && analysis.lines[i] === line;
|
|
const toks = fresh ? (tokByLine[i] || []) : [];
|
|
toks.sort((a,b)=>a.s-b.s);
|
|
let pos = 0, h = '';
|
|
toks.forEach(t=>{
|
|
const g = groupInfo[t.g]; if(!g || t.s < pos) return;
|
|
h += esc(line.slice(pos, t.s));
|
|
const cls = 'rh' + (t.end ? ' end' : '') + (t.slant ? ' slant' : '');
|
|
h += `<span class="${cls}" style="--c:var(--r${g.color % COLORS})">` + esc(line.slice(t.s, t.e)) + '</span>';
|
|
pos = t.e;
|
|
});
|
|
h += esc(line.slice(pos));
|
|
html += h + '\n';
|
|
});
|
|
highlight.innerHTML = html;
|
|
highlight.scrollTop = editor.scrollTop;
|
|
highlight.scrollLeft = editor.scrollLeft;
|
|
buildReadout();
|
|
buildLegend();
|
|
}
|
|
|
|
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 st = caretStanza();
|
|
if(!st || !st.scheme){ schemeReadout.innerHTML = ''; return; }
|
|
schemeReadout.innerHTML = `stanza scheme <b>${st.scheme.toUpperCase().split('').join(' ')}</b>`;
|
|
}
|
|
|
|
function buildLegend(){
|
|
const box = document.getElementById('legendBox');
|
|
const st = caretStanza();
|
|
if(!st || !st.legend.length){ box.innerHTML = '<p class="muted">No rhymes detected yet.</p>'; return; }
|
|
box.innerHTML = st.legend.map(l=>{
|
|
const sw = l.color === null
|
|
? `<span class="swatch" style="background:#3a342d"></span>`
|
|
: `<span class="swatch" style="background:var(--r${l.color % COLORS})"></span>`;
|
|
const note = l.color === null ? ' — no rhyme' : (l.slant ? ' — slant' : '');
|
|
return `<div class="legend-item">${sw} ending “${l.ch}”${note}</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
editor.addEventListener('input', ()=>{ render(); analyzeSoon(); });
|
|
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = editor.scrollTop; highlight.scrollLeft = editor.scrollLeft; });
|
|
editor.addEventListener('keyup', ()=>{ buildReadout(); buildLegend(); });
|
|
editor.addEventListener('click', ()=>{ buildReadout(); buildLegend(); });
|
|
|
|
/* ---------- double-click a word -> look it up ---------- */
|
|
editor.addEventListener('dblclick', ()=>{
|
|
const sel = editor.value.slice(editor.selectionStart, editor.selectionEnd)
|
|
.trim().replace(/[^A-Za-z']/g,'');
|
|
if(sel){ document.getElementById('lookupInput').value = sel; doLookup(); }
|
|
});
|
|
|
|
/* ---------- insert at cursor ---------- */
|
|
function insertAtCursor(word){
|
|
const s = editor.selectionStart, e = editor.selectionEnd;
|
|
const v = editor.value;
|
|
const needSpace = s > 0 && !/\s/.test(v[s-1]);
|
|
const ins = (needSpace ? ' ' : '') + word;
|
|
editor.value = v.slice(0,s) + ins + v.slice(e);
|
|
const np = s + ins.length;
|
|
editor.focus();
|
|
editor.setSelectionRange(np, np);
|
|
render();
|
|
analyzeSoon();
|
|
}
|
|
|
|
/* ============================================================
|
|
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 = 'rhyme';
|
|
modeSeg.addEventListener('click', e=>{
|
|
const b = e.target.closest('button'); if(!b) return;
|
|
[...modeSeg.children].forEach(c=>c.classList.remove('active'));
|
|
b.classList.add('active'); mode = b.dataset.mode; doLookup();
|
|
});
|
|
document.getElementById('lookupBtn').addEventListener('click', doLookup);
|
|
document.getElementById('lookupInput').addEventListener('keydown', e=>{ if(e.key==='Enter') doLookup(); });
|
|
|
|
const resultsBox = document.getElementById('lookupResults');
|
|
async function doLookup(){
|
|
const word = document.getElementById('lookupInput').value.trim();
|
|
if(!word) return;
|
|
resultsBox.innerHTML = '<p class="muted">Searching…</p>';
|
|
try{
|
|
if(mode === 'syn'){
|
|
const r = await fetch(`https://api.datamuse.com/words?rel_syn=${encodeURIComponent(word)}&ml=${encodeURIComponent(word)}&max=60`);
|
|
const data = await r.json();
|
|
renderChips(`Synonyms / related for “${esc(word)}”`, data.map(d=>d.word));
|
|
}else{
|
|
const r = await fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=${mode}`);
|
|
const data = await r.json();
|
|
if(!data.known){
|
|
resultsBox.innerHTML = `<p class="muted">“${esc(word)}” isn't in the pronunciation dictionary.</p>`;
|
|
return;
|
|
}
|
|
renderBySyllable(word, data.words, mode === 'near' ? 'Near rhymes' : 'Rhymes');
|
|
}
|
|
}catch(err){
|
|
resultsBox.innerHTML = `<p class="muted">Lookup failed — is the backend running? (${esc(String(err))})</p>`;
|
|
}
|
|
}
|
|
|
|
function chipHtml(words){
|
|
return '<div class="results">' +
|
|
words.map(w=>`<span class="chip" data-w="${esc(w)}">${esc(w)}</span>`).join('') +
|
|
'</div>';
|
|
}
|
|
function wireChips(){
|
|
resultsBox.querySelectorAll('.chip').forEach(c=>{
|
|
c.addEventListener('click', ()=> insertAtCursor(c.dataset.w));
|
|
});
|
|
}
|
|
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 renderBySyllable(word, items, label){
|
|
if(!items.length){ resultsBox.innerHTML = `<p class="muted">No results for “${esc(word)}”.</p>`; return; }
|
|
const bySyl = {};
|
|
items.forEach(d=>{ (bySyl[d.syl || 0] ||= []).push(d.word); });
|
|
let h = `<div class="res-label">${label} for “${esc(word)}”</div>`;
|
|
Object.keys(bySyl).sort((a,b)=>a-b).forEach(k=>{
|
|
h += `<div class="res-label">${k == 0 ? '?' : k} syllable${k == 1 ? '' : 's'}</div>` + chipHtml(bySyl[k]);
|
|
});
|
|
resultsBox.innerHTML = h;
|
|
wireChips();
|
|
}
|
|
|
|
/* ============================================================
|
|
STANZA REORDER — drag cards to rearrange blank-line-separated
|
|
stanzas; the draft updates on every drop.
|
|
============================================================ */
|
|
const overlay = document.getElementById('reorderOverlay');
|
|
const stanzaList = document.getElementById('stanzaList');
|
|
let blocks = [];
|
|
|
|
function openReorder(){
|
|
blocks = editor.value.split(/\n{2,}/).map(b=>b.trim()).filter(Boolean);
|
|
if(blocks.length < 2){ flash('reorderBtn','Need 2+ stanzas'); return; }
|
|
buildCards();
|
|
overlay.hidden = false;
|
|
}
|
|
function schemeOf(block){
|
|
if(!analysis) return '';
|
|
const first = block.split('\n')[0];
|
|
const idx = analysis.lines.indexOf(first);
|
|
if(idx < 0) return '';
|
|
const st = analysis.stanzas.find(s=>s.lines.includes(idx));
|
|
return st ? st.scheme : '';
|
|
}
|
|
function buildCards(){
|
|
stanzaList.innerHTML = '';
|
|
blocks.forEach((b, i)=>{
|
|
const lines = b.split('\n');
|
|
const card = document.createElement('div');
|
|
card.className = 'stanza-card';
|
|
card.draggable = true;
|
|
card.dataset.idx = i;
|
|
const scheme = schemeOf(b);
|
|
card.innerHTML = `
|
|
<span class="grip">⠿</span>
|
|
<span class="preview">
|
|
<div class="first-line">${esc(lines[0])}</div>
|
|
<div class="meta-line">${lines.length} line${lines.length===1?'':'s'}</div>
|
|
</span>
|
|
${scheme ? `<span class="scheme-badge">${scheme.split('').join(' ')}</span>` : ''}`;
|
|
card.addEventListener('dragstart', ()=>{ dragIdx = i; card.classList.add('dragging'); });
|
|
card.addEventListener('dragend', ()=>{ dragIdx = null; card.classList.remove('dragging'); clearDrops(); });
|
|
card.addEventListener('dragover', e=>{
|
|
e.preventDefault();
|
|
clearDrops();
|
|
const before = e.offsetY < card.offsetHeight / 2;
|
|
card.classList.add(before ? 'drop-above' : 'drop-below');
|
|
});
|
|
card.addEventListener('drop', e=>{
|
|
e.preventDefault();
|
|
if(dragIdx === null) return;
|
|
const before = e.offsetY < card.offsetHeight / 2;
|
|
let to = i + (before ? 0 : 1);
|
|
const [moved] = blocks.splice(dragIdx, 1);
|
|
if(dragIdx < to) to--;
|
|
blocks.splice(to, 0, moved);
|
|
applyBlocks();
|
|
buildCards();
|
|
});
|
|
stanzaList.appendChild(card);
|
|
});
|
|
}
|
|
let dragIdx = null;
|
|
function clearDrops(){
|
|
stanzaList.querySelectorAll('.stanza-card').forEach(c=>c.classList.remove('drop-above','drop-below'));
|
|
}
|
|
function applyBlocks(){
|
|
editor.value = blocks.join('\n\n');
|
|
render();
|
|
analyzeSoon();
|
|
}
|
|
document.getElementById('reorderBtn').addEventListener('click', openReorder);
|
|
document.getElementById('reorderDone').addEventListener('click', ()=>{ overlay.hidden = true; editor.focus(); });
|
|
overlay.addEventListener('click', e=>{ if(e.target === overlay){ overlay.hidden = true; editor.focus(); } });
|
|
document.addEventListener('keydown', e=>{ if(e.key === 'Escape' && !overlay.hidden){ overlay.hidden = true; editor.focus(); } });
|
|
|
|
/* ============================================================
|
|
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);
|
|
}
|
|
document.getElementById('clearBtn').addEventListener('click', ()=>{
|
|
if(editor.value.trim()){
|
|
try{ localStorage.setItem(DRAFT_KEY + '.backup', editor.value); }catch(e){}
|
|
}
|
|
editor.value=''; analysis=null; render(); analyze(); editor.focus();
|
|
});
|
|
document.getElementById('sampleBtn').addEventListener('click', ()=>{
|
|
editor.value =
|
|
`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
|
|
|
|
Pressure make a diamond, grind it out the coal I hold
|
|
stories that I never told, weathered and a decade old
|
|
chasing something warmer than a chain of gold
|
|
staying up till every borrowed hour is sold`;
|
|
render(); analyze(); editor.focus();
|
|
});
|
|
|
|
/* ============================================================
|
|
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, 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)(); }
|
|
|
|
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(actx.destination); 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(actx.destination); 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(actx.destination); 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);
|
|
|
|
render();
|
|
analyze();
|
|
</script>
|
|
</body>
|
|
</html>
|