mirror of
https://github.com/kennethreitz/rhymepad.org.git
synced 2026-06-11 17:08:33 +00:00
b2788e3620
Beats: every pattern gets an 808 bass line (pitch-glide sine, per- style note movement), trap/halftime get layered claps, lo-fi/jazz get open hats, hats humanize velocity. Two new styles (Halftime, Dembow), a tap-tempo button, and a four-dot beat indicator synced to the bar. Cadence: flexible monosyllables (x) read as stressed, so contours differing only in flex positions join one flow family. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1245 lines
50 KiB
HTML
1245 lines
50 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 — 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 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: 1.9;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
overflow-y: auto;
|
||
letter-spacing: 0;
|
||
tab-size: 4;
|
||
}
|
||
.editor-shell.rhythm #highlight,
|
||
.editor-shell.rhythm #stresslayer,
|
||
.editor-shell.rhythm #editor { line-height: 2.35; }
|
||
#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.indraft::after { content: " ✓"; color: var(--accent-2); }
|
||
.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; align-items:center; }
|
||
.beat-ind { display:flex; gap:6px; margin-left:auto; }
|
||
.beat-ind span {
|
||
width:9px; height:9px; border-radius:50%;
|
||
background: var(--line); transition: background .05s;
|
||
}
|
||
.beat-ind span.on { background: var(--accent); }
|
||
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 & 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>
|
||
<button class="btn" id="downloadBtn" title="Download this draft as a .txt file">Save .txt</button>
|
||
<label class="mtoggle" title="Color-code rhyme families"><input type="checkbox" id="rhymeToggle" checked> rhyme</label>
|
||
<label class="mtoggle" title="Underline words that share an initial sound"><input type="checkbox" id="allitToggle"> alliteration</label>
|
||
<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 & 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>
|
||
<button class="btn" id="tapBtn" title="Tap a few times to set the tempo">Tap</button>
|
||
<div class="beat-ind" id="beatInd"><span></span><span></span><span></span><span></span></div>
|
||
</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', ()=>{
|
||
document.querySelector('.editor-shell').classList.toggle('rhythm', stressToggle.checked);
|
||
render();
|
||
});
|
||
const allitToggle = document.getElementById('allitToggle');
|
||
allitToggle.checked = false;
|
||
allitToggle.addEventListener('change', render);
|
||
const rhymeToggle = document.getElementById('rhymeToggle');
|
||
rhymeToggle.checked = true;
|
||
rhymeToggle.addEventListener('change', render);
|
||
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;
|
||
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 allitByLine = {};
|
||
const openByLine = {};
|
||
const groupInfo = {};
|
||
if(analysis){
|
||
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
|
||
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
|
||
if(analysis.allit) analysis.allit.forEach(t=>{ (allitByLine[t.l] ||= []).push(t); });
|
||
if(analysis.open) analysis.open.forEach(t=>{ (openByLine[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 = rhymeToggle.checked ? raw.filter(t=>groupInfo[t.g]) : [];
|
||
const words = toks.filter(t=>!t.ph);
|
||
const phrases = toks.filter(t=>t.ph);
|
||
const als = (allitToggle.checked && fresh) ? (allitByLine[i] || []) : [];
|
||
const opens = (rhymeToggle.checked && fresh) ? (openByLine[i] || []) : [];
|
||
let h = '';
|
||
if(!toks.length && !als.length && !opens.length){
|
||
h = esc(line);
|
||
}else{
|
||
const cuts = new Set([0, line.length]);
|
||
toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
|
||
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);
|
||
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);
|
||
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);
|
||
// gray = an ending still waiting for its answer
|
||
let style = '';
|
||
if(w || p){
|
||
const alpha = w ? (w.end ? 34 : 14) : (p.end ? 24 : 10);
|
||
const color = w ? colorOf(w) : colorOf(p);
|
||
style += `background:color-mix(in srgb, ${color} ${alpha}%, 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);`;
|
||
h += `<span class="hseg" style="${style}">${text}</span>`;
|
||
}
|
||
}
|
||
html += h + '\n';
|
||
});
|
||
highlight.innerHTML = html;
|
||
renderStress(lines);
|
||
highlight.scrollTop = editor.scrollTop;
|
||
highlight.scrollLeft = editor.scrollLeft;
|
||
buildReadout();
|
||
}
|
||
|
||
function cadenceColors(){
|
||
// exact stress-contour matches (5+ syllables) form a flow family
|
||
const map = {};
|
||
if(!analysis || !analysis.meter) return map;
|
||
const byPat = {};
|
||
analysis.meter.forEach(m=>{
|
||
if(!m.stress || m.stress.length < 5) return;
|
||
// flexible monosyllables (x) read as stressed in delivery, so
|
||
// "x01" and "101" are the same flow
|
||
const key = m.stress.replace(/x/g, '1');
|
||
(byPat[key] ||= []).push(m.l);
|
||
});
|
||
let fid = 0;
|
||
Object.keys(byPat).sort().forEach(pat=>{
|
||
const lns = byPat[pat];
|
||
if(lns.length >= 2){ lns.forEach(l=>{ map[l] = fid % COLORS; }); fid++; }
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function renderStress(lines){
|
||
if(!stressToggle.checked){ stresslayer.innerHTML = ''; return; }
|
||
const byLine = {};
|
||
if(analysis && analysis.stress) analysis.stress.forEach(s=>{ (byLine[s.l] ||= []).push(s); });
|
||
const cmap = cadenceColors();
|
||
let html = '';
|
||
lines.forEach((line, i)=>{
|
||
const fresh = analysis && analysis.lines[i] === line;
|
||
let raw = [];
|
||
if(fresh){
|
||
raw = byLine[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 = (byLine[i] || []).filter(s=>s.e <= cp);
|
||
}
|
||
const spans = raw.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('');
|
||
const col = cmap[i] != null ? ` style="color:var(--r${cmap[i]})"` : '';
|
||
h2 += `<span class="sw">${esc(line.slice(s.s, s.e))}<span class="sd"${col}>${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){
|
||
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 <b>${shown}</b>`);
|
||
}
|
||
schemeReadout.innerHTML = parts.join(' ');
|
||
}
|
||
|
||
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}; words already in the draft get a tick
|
||
const inDraft = new Set((editor.value.toLowerCase().match(/[a-z']+/g)) || []);
|
||
return '<div class="results">' +
|
||
items.map(d=>{
|
||
const w = d.word || d, r = rarity(d.z);
|
||
const used = inDraft.has(w.toLowerCase()) ? ' indraft' : '';
|
||
return `<span class="chip${cls ? ' ' + cls : ''}${r}${used}" 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.senses >= 3 ? ` · ${info.senses} senses` : '') +
|
||
((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 rhythm = stressToggle.checked && analysis && analysis.stress;
|
||
const S = 2, FS = 16, LH = FS * (rhythm ? 2.35 : 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 = (rhymeToggle.checked && 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;
|
||
}
|
||
if(allitToggle.checked && fresh){
|
||
(analysis.allit || []).filter(t=>t.l===i).forEach(t=>{
|
||
const left = PAD + x.measureText(line.slice(0, t.s)).width;
|
||
const wpx = x.measureText(line.slice(t.s, t.e)).width;
|
||
x.globalAlpha = 0.75;
|
||
x.fillStyle = palette[t.g % COLORS];
|
||
x.fillRect(left - 1, y + FS * 0.55, wpx + 2, 2);
|
||
x.globalAlpha = 1;
|
||
});
|
||
}
|
||
x.fillStyle = ink;
|
||
x.fillText(line, PAD, y);
|
||
if(rhythm && fresh){
|
||
const spans = (analysis.stress.filter(s=>s.l===i)).sort((a,b)=>a.s-b.s);
|
||
x.font = "8px 'Spline Sans Mono', monospace";
|
||
x.textAlign = 'center';
|
||
spans.forEach(s=>{
|
||
// measure with the body font for positions
|
||
x.font = font;
|
||
const left = PAD + x.measureText(line.slice(0, s.s)).width;
|
||
const wpx = x.measureText(line.slice(s.s, s.e)).width;
|
||
x.font = "9px 'Spline Sans Mono', monospace";
|
||
const dots = [...s.st].map(c=> c === '0' ? '○' : '●').join(' ');
|
||
x.fillText(dots, left + wpx / 2, y + FS * 0.95);
|
||
});
|
||
x.textAlign = 'left';
|
||
x.font = font;
|
||
}
|
||
});
|
||
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');
|
||
});
|
||
});
|
||
|
||
/* ============================================================
|
||
FILES IN, FILES OUT — drafts shouldn't be hostage to localStorage
|
||
============================================================ */
|
||
function draftFilename(){
|
||
const doc = docsState.docs.find(d=>d.id===docsState.current);
|
||
return (((doc && doc.title && doc.title !== 'Untitled') ? doc.title : 'rhymepad')
|
||
.replace(/[^\w\- ]+/g, '').trim() || 'rhymepad') + '.txt';
|
||
}
|
||
document.getElementById('downloadBtn').addEventListener('click', ()=>{
|
||
if(!editor.value.trim()) return;
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([editor.value], {type: 'text/plain'}));
|
||
a.download = draftFilename();
|
||
a.click();
|
||
setTimeout(()=>URL.revokeObjectURL(a.href), 5000);
|
||
flash('downloadBtn', 'Saved \u2713');
|
||
});
|
||
// drop a .txt (or any text file) anywhere -> it becomes a new draft
|
||
document.addEventListener('dragover', e=>{ e.preventDefault(); });
|
||
document.addEventListener('drop', async e=>{
|
||
e.preventDefault();
|
||
const files = [...(e.dataTransfer?.files || [])].slice(0, 8);
|
||
for(const f of files){
|
||
if(f.size > 1024 * 1024) continue;
|
||
const text = await f.text();
|
||
if(!text.trim()) continue;
|
||
const id = newId();
|
||
docsState.docs.push({id, title: titleOf(text)});
|
||
localStorage.setItem(docKey(id), text);
|
||
openDoc(id);
|
||
}
|
||
});
|
||
|
||
/* ============================================================
|
||
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)));
|
||
|
||
// bass: [step, semitone offset, length in 16ths]
|
||
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,
|
||
bass:[[0,0,2],[6,0,1],[8,0,2],[14,3,1]]},
|
||
'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,
|
||
clap:true, bass:[[0,0,3],[7,-2,1],[8,0,3],[11,3,2]]},
|
||
'Lo-Fi': {bpm:75, kick:[0,8], snare:[4,12], hat:[2,6,10,14], swing:0.18,
|
||
ohat:[10], bass:[[0,0,3],[8,5,3],[12,3,2]]},
|
||
'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,
|
||
bass:[[0,0,2],[3,1,2],[6,-2,2],[10,0,1],[11,3,2]]},
|
||
'Spoken/Jazz':{bpm:96, kick:[0,10], snare:[4,12], hat:[0,3,6,9,12,15], swing:0.2,
|
||
ohat:[14], bass:[[0,0,2],[4,7,2],[8,5,2],[12,3,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,
|
||
bass:[[0,0,2],[6,0,1],[8,-4,2],[14,0,1]]},
|
||
'Halftime': {bpm:74, kick:[0,10], snare:[8], hat:[0,2,4,6,8,10,12,14], swing:0.1,
|
||
clap:true, ohat:[6], bass:[[0,0,6],[10,-2,4]]},
|
||
'Dembow': {bpm:96, kick:[0,4,8,12], snare:[3,6,11,14], hat:[0,2,4,6,8,10,12,14], swing:0,
|
||
bass:[[0,0,3],[6,0,1],[8,0,3],[14,0,1]]}
|
||
};
|
||
|
||
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.2 + Math.random()*0.12, t); // humanized
|
||
g.gain.exponentialRampToValueAtTime(0.001,t+0.05);
|
||
noise.connect(f).connect(g).connect(masterGain); noise.start(t); noise.stop(t+0.06);
|
||
}
|
||
function ohat(t){
|
||
const noise=actx.createBufferSource();
|
||
const buf=actx.createBuffer(1, actx.sampleRate*0.3, 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=6000;
|
||
const g=actx.createGain(); g.gain.setValueAtTime(0.16,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.28);
|
||
noise.connect(f).connect(g).connect(masterGain); noise.start(t); noise.stop(t+0.3);
|
||
}
|
||
function clap(t){
|
||
for(let k=0;k<3;k++){
|
||
const n=actx.createBufferSource();
|
||
const buf=actx.createBuffer(1, actx.sampleRate*0.08, actx.sampleRate);
|
||
const d=buf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
|
||
n.buffer=buf;
|
||
const f=actx.createBiquadFilter(); f.type='bandpass'; f.frequency.value=1500; f.Q.value=1.2;
|
||
const g=actx.createGain(); const tt=t+k*0.012;
|
||
g.gain.setValueAtTime(0.3,tt); g.gain.exponentialRampToValueAtTime(0.001,tt+0.09);
|
||
n.connect(f).connect(g).connect(masterGain); n.start(tt); n.stop(tt+0.12);
|
||
}
|
||
}
|
||
function bass(t, semi, lenSteps, secPer16){
|
||
const o=actx.createOscillator(), g=actx.createGain();
|
||
o.type='sine';
|
||
const f0 = 55 * Math.pow(2, semi/12);
|
||
o.frequency.setValueAtTime(f0*2, t);
|
||
o.frequency.exponentialRampToValueAtTime(f0, t+0.035);
|
||
const len = Math.max(0.12, lenSteps * secPer16 * 0.95);
|
||
g.gain.setValueAtTime(0.5, t);
|
||
g.gain.exponentialRampToValueAtTime(0.001, t+len);
|
||
o.connect(g).connect(masterGain); o.start(t); o.stop(t+len+0.05);
|
||
}
|
||
|
||
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.clap) clap(t); }
|
||
if(p.hat.includes(s)) hat(t);
|
||
if(p.ohat && p.ohat.includes(s)) ohat(t);
|
||
if(p.bass) p.bass.forEach(([bs, semi, len])=>{ if(bs === s) bass(t, semi, len, secPer16); });
|
||
if(s % 4 === 0){
|
||
const beat = s / 4;
|
||
setTimeout(()=>flashBeat(beat), Math.max(0, (t - actx.currentTime) * 1000));
|
||
}
|
||
nextTime += secPer16;
|
||
step++;
|
||
}
|
||
schedTimer = setTimeout(scheduler, 25);
|
||
}
|
||
const beatInd = document.getElementById('beatInd');
|
||
function flashBeat(n){
|
||
if(!playing) return;
|
||
[...beatInd.children].forEach((d, i)=> d.classList.toggle('on', i === n));
|
||
}
|
||
const taps = [];
|
||
document.getElementById('tapBtn').addEventListener('click', ()=>{
|
||
const now = performance.now();
|
||
if(taps.length && now - taps[taps.length - 1] > 2000) taps.length = 0;
|
||
taps.push(now);
|
||
if(taps.length >= 2){
|
||
const iv = (taps[taps.length - 1] - taps[0]) / (taps.length - 1);
|
||
setTempo(Math.max(60, Math.min(180, Math.round(60000 / iv))));
|
||
}
|
||
});
|
||
|
||
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);
|
||
[...beatInd.children].forEach(d=>d.classList.remove('on'));
|
||
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>
|