Skip to main content
Peckham
PROJECT
LYRIIC
ProductTypeScriptCompression

lyriic

I meant to write a song but built a local-first lyric editor instead. It's got client-side rhymes, synonyms, and bit-packed dictionaries so syllable counting stays out of the way.

I was supposed to write a song

I was watching a video about songwriting. Halfway through I got that familiar itch: I could do that. Not chart-topper confidence. More like "I have a notebook and a free evening" confidence.

So I sat down to write.

The first verse went fine until I started checking meter. How many syllables in this line? Where's the stress on desire? Does anyone actually rhyme with fun, or am I cheating? I kept tapping fingers on the desk, muttering counts under my breath, losing the line I was trying to catch.

That counting loop is death for the zen of writing. The draft wants flow. Your brain wants a spreadsheet.

I never finished the song. I spent the weekend building lyriic instead.

lyriic is a free, local-first editor for poetry and lyrics in meter. Big quiet canvas. Per-line syllable counts. Optional meter rulers. Hover a word for rhymes and synonyms. No account. No AI that writes the verse for you. Your draft stays in the browser.

This is the weekend's technical hangover: how to ship a usable dictionary brain to the client without murdering the first paint, and how to make rhyme and synonym lookups feel instant once the packs land.

Try the itch that started it. Type a line. Watch the counts show up. That quiet number on the right is what I wanted in the corner of my eye, not a ritual that steals the stanza.

Interactive

Syllable scratchpad

Type a line. Watch the count climb. This is the part that used to kill the mood.

1I
1could
1write
1a
1song
2tonight
line total 7

Demo counts only — lyriic uses a fused IPA dictionary plus overrides.

Takeaway

The product goal was simple: keep the musical part of writing, automate the finger-counting part.

What "fast enough" means when the dictionary is the app

Most rhyme sites are a text box glued to a server. That works until you're offline on a plane, or the API hiccups mid-couplet, or you notice your unfinished chorus just left your laptop.

I wanted the opposite. Treat the lexicon like a game asset. Download it once. Decode it. Look things up in memory. The poem never needs a round trip.

The awkward part is size. A useful English brain is not small:

  • ~276k lemmas with syllable counts
  • stress patterns for the same set
  • sparse poetic variants (fire as 2 syllables or a compressed 1)
  • perfect-rhyme and end-rhyme indexes
  • a thesaurus with POS-aware synonym groups

Dump that as JSON and you're looking at tens of megabytes before the editor feels smart. Fine for a native app install. Rude for a website you opened because you had an idea in the shower.

So the weekend became a compression problem with a UX constraint: the writing surface has to feel alive before the heavy packs finish, and lookups have to stay O(bucket), not "tokenize IPA on every keystroke."

Front-coding the word list

Lemma lists are almost sorted already if you sort them. Adjacent words share prefixes: sing, singer, singers, singing. Storing each string whole is paying rent on the same letters over and over.

Front-coding stores three things per word: how many characters you reuse from the previous word (u8), how long the new tail is (u16), and the tail bytes. Decode walks the list once and rebuilds full strings into a string[] plus a Map for O(1) word→id.

Play the sing* cluster. Blue is the shared prefix you don't resend. Yellow is the tail that actually ships.

Interactive

Front-coding

Sorted words share prefixes. Store the overlap once as a length, then only the new tail.

As plain strings

  • sing4B
  • singer6B
  • singers7B
  • singing7B
  • single6B
  • singly6B
  • sink4B
  • sinking7B

Front-coded on the wire

  • shared=0 sing7B
Raw characters47 B
Front-coded41 B
Prefix eliminated6 B (13%)

Outcome

13% fewer character bytes in this cluster

On the real lexicon (~276k lemmas) front-coding drops stored character payload to about 31% of the raw letters before gzip/brotli even starts.

shared:u8 + restLen:u16LE + UTF-8 rest
Takeaway

On the real lexicon, front-coding alone cuts stored character payload to about 31% of the raw letters, and that's before brotli.

Every other pack (rhymes, thesaurus, stress, variants) talks in word ids, not strings. One Zipf-sorted lexicon. Everything else is integers pointing at it. The thesaurus gets a small overflow string table for synonyms that aren't headwords in the main list. That's it.

Two bits per syllable

Stress is even more wasteful if you store it as text. I don't need the string "primary, unstressed, secondary". I need three values that fit in two bits:

codemeaning
0unstressed
1primary
2secondary

Pack those into a u32, low syllable in the low bits. Sixteen syllables max, which is plenty for English dictionary forms. The entire stress table becomes one Uint32Array aligned with word ids: about 1.05 MiB raw, ~90 KB after brotli.

Tap the syllables below. Watch the bit lanes and the hex value update. Same packing lyriic uses in stress.bin.

Interactive

2-bit stress packing

Each syllable gets two bits: unstressed, primary, or secondary. Sixteen syllables fit in one u32.

Tap a syllable to cycle stress

Bit lanes (low syllable → low bits)

10
<< 0
00
<< 2

packed bits (high→low): 0001

One word → one u32

0x00000001

decimal 1

2 syllables4 bits used4 bytes stored

Outcome

276k words × 4 bytes ≈ 1.05 MiB

The whole stress table is a dense Uint32Array. No strings. After brotli it shrinks to about 90 KB on the wire.

Takeaway

Prosody for a quarter-million words fits in a dense integer array. No JSON keys. No per-word string patterns.

Poetic alternatives (the times you want fire as one syllable) live in a sparse variants pack: only the ~10k words that need alts, each with a syllable count and another packed stress u32. The common case stays skinny.

Rhymes without doing phonology at query time

The clever bit isn't a clever search. It's refusing to search.

At build time I fuse pronunciations (Misaki → CMUdict → WikiPron, with a preference order) and extract rhyme keys from IPA:

  • Perfect rhyme key: phones from the last primary stress through the coda (desire/aɪɚ/, same as fire)
  • End rhyme key: last vowel nucleus through the coda, stress ignored (anyone and fun both end in /ʌn/)

Those keys get front-coded too. Then two inverted indexes:

  1. wordId → keyIds (a word can have more than one pronunciation)
  2. keyId → wordIds in Zipf order (common rhymes first)

Query path:

normalize(word) → wordId → keyIds → walk buckets → dedupe → rank for UI

No IPA tokenization while you type. Integer map lookups and array walks. The popover asks for ids first, windows the head of the bucket, then materializes strings, so a fat end-rhyme bucket doesn't explode the DOM.

Watch a key get cut out of a phone strip. Flip between perfect and end to see why butter/meter collapse together on end rhyme but not on perfect.

Interactive

Rhyme keys from IPA

Build time does the phonology once. Query time only compares integer key ids.

IPA phones · desire

dɪ·zˈɚ·
perfect key

/aɪɚ/

Outcome

Perfect: from the last primary stress through the coda

desire → /aɪɚ/. Same key as fire and hire.

Takeaway

Phonology is a build step. Runtime rhyme is an index walk.

Then watch the lookup itself step through on a toy lexicon:

Interactive

Rhyme lookup walk

No IPA tokenization at query time. Integer ids and prebuilt buckets.

1. normalize → wordId2. wordId → keyIds3. keyId → Zipf bucket4. window + rank for UI

Word map

funid 3

Keys for word

/ʌn/

end key id (demo)

Bucket (Zipf order)

  • someone2 syl
  • anyone3 syl

Outcome

fun → /ʌn/ → anyone, someone, …

Production packs keep ~95k perfect keys and only ~2.3k end keys. End buckets are fatter; the UI windows the Zipf head before ranking for meter.

Production scale, for the curious: ~95k perfect keys (small buckets, picky matches) vs ~2.3k end keys (huge buckets, songwriter-friendly). End rhyme is the mode that makes anyone/fun feel legal. Perfect is what you want when you're being a snob in a good way.

Synonyms with just enough grammar

The thesaurus pack (LYXT) is OEWN synset neighbors plus Wiktionary synonym links, ranked OEWN-first, then Wiktionary fill. Heads point at usage blocks: noun / verb / adjective / adverb, each a list of synonym ids.

Runtime adds two soft skills on top of the dump:

  1. Inflection bridging: typed remains, look up remain
  2. POS guess from the line: determiner before the word? Prefer nouns. to / aux / pronoun before it? Prefer verbs.

Candidates then get the same meter-aware ranking as rhymes: POS match, syllable fit, Zipf. The helper is trying to hand you a word that still scans, not a thesaurus firework.

I won't pretend the POS detector is a full parser. It's a cheap prior that is right often enough to put the useful synonyms first. When it's wrong, the other usages are still one tap away.

The wire budget

Put the packs side by side. Naive JSON for words-plus-perfect-rhymes alone is already ~12 MiB in the measurements I kept. The custom binaries for everything (lexicon, stress, variants, both rhyme modes, thesaurus) land around 8.6 MiB raw and ~3.7 MiB with brotli.

Interactive

Dictionary on the wire

Six binary packs. Shared word ids. Front-coding, uvarints, and 2-bit stress. Then brotli.

On the wire (brotli) · ~3.68 MiB

lexicon0.56stress0.09variants0.02rhyme perfect1.40rhyme end0.84thesaurus0.77
Naive JSON (words + perfect rhyme)~12.5 MiB
All six packs, raw~8.57 MiB
All six packs, brotli~3.68 MiB
276,493 lemmas95,118 perfect keys2,308 end keys54,672 thesaurus heads

Outcome

~3.7 MiB brotli for the whole linguistic brain

Lexicon, stress, poetic variants, both rhyme indexes, and the thesaurus — decoded in a worker after the editor is already interactive.

Takeaway

~3.7 MiB brotli is the whole linguistic brain. The editor idle-schedules loads and decodes in a worker so the canvas doesn't hitch.

Load order matters as much as bytes. Lexicon first (counts in the gutter). Stress and variants next (rulers get honest). Thesaurus and rhymes prefetch on idle, delayed further on Save-Data / slow networks. After the assets are cached you're effectively offline for writing.

The zen constraint

I kept catching myself adding "helpful" chrome. Candidate drawers that never shut up. Colored stress marks on every syllable. A panel that scores your stanza like a gym coach.

Every one of those features fights the original complaint. I didn't abandon the song because I lacked dashboards. I abandoned it because checking the line felt like work.

So lyriic stays a big text surface. Counts whisper. Tools appear when you ask a word a question. Meter rulers are optional. Overrides exist for the words dictionaries argue about. The poem is still yours.

If you want the product without the pack-format tour, it's at lyriic.com. If you want to see whether anyone/fun feels like cheating in end-rhyme mode, open a draft and tap the word.

Loose ends

Rhyme keys are only as good as the fused pronunciation sources. Proper nouns and slang still fall through to heuristics. End-rhyme buckets can be enormous ( is a planet); ranking and windowing hide that, but a multi-key "slant" index might beat one fat coda bucket someday. Collaborative drafts would break the "never leaves the device" story, and I'm fine with that trade for v1.

I still haven't written the song. I did write a syllable counter that doesn't harsh my vibe. For a weekend where the muse got replaced by a binary packer, I'll take it.