Jonathan Mellette Contact

Solutions architecture · Live at shout.mellette.net · Personal project

Whisper’s Shout

A songbook on the web that opens, turns and sings like a real book: five AI-illustrated, AI-sung lullabies from a tabletop character’s backstory, with no build step and no package dependencies. The next morning, its page engine became the base of a website engine for small congregations.

Status
Live since 21 Sep 2026. Unversioned; the live build is stamped 6172fbb.
Licence
None stated: a personal project in a private repository, outside HonedEdge. The vendored page-curl library, page-flip 2.0.7, is MIT.
Media
AI-generated: illustrations by ChatGPT’s image model, recordings by Suno
Stack
HTML · CSS · JavaScript with no build step · Web Audio · Web Animations · rsync deploy
Links
shout.mellette.net · source is private

Measured · how

The constraint

The art and recordings hadn’t arrived yet and would keep being regenerated, so the book had to go live complete without them.

At a glance

pages in the book, holding 5 songs and 9 min 51 s of sung audio
16
of HTML, CSS and script over the wire on a cold load, against 17.4 MB of illustrations
38KB
package dependencies; the one vendored library is byte-identical to upstream
0
from an empty repository to the book live on the web
<50min
How these were measured
  • 16 pages: counted on the live site in headless Chromium (Playwright) on 12 Oct 2026, matching the page plan in js/book.js (6 fixed pages plus 2 per song). The five recordings’ durations, read with afinfo, sum to 591.3 s.
  • 38 KB: a cold load of the live site at 1600×1000 in headless Chromium. The six HTML, CSS and script responses total 37,732 bytes compressed; the PNG illustrations total 17.4 MB.
  • 0: git ls-files lists no package.json, lockfile or node_modules. The vendored page-flip script matches the published 2.0.7 package by SHA-256, and its stylesheet matches by diff.
  • <50 min: the repository directory was created at 18:36 on 21 Sep 2026, and a commit at 19:26 records a fault found on the live page after its second deploy.
An open spread: on the left a full-page sketch of a laughing grandmother dancing a kitten round a kitchen; on the right the title Grandmother’s Weather, eight sung lines each set on two, and a round wax-seal play button.
Fig. 1 Song 3’s spread at 1600×1000: a full-page illustration on the left; the title, the verse with each sung line set on two, the wax-seal play button and the folio on the right. AI-generated illustration (ChatGPT’s image model).

The short version

  1. Held three AI generators to one fixed asset contract, so the book went live complete, with a placeholder in every slot, about two hours before the recordings it now plays were generated.
  2. Worked around five behaviours of a third-party library from the outside, without forking it (one only in part; see the honest notes). The vendored copy is still byte-identical to upstream, and the whole book stays on screen at 11 of 12 viewports tested.
  3. Built a deploy that can’t serve a stale page: every script, stylesheet and icon URL carries the commit hash, every request (illustrations and recordings included) revalidates, and a revisit costs a 304.

Case 1 of 5

Three AI tools, one asset contract

The art and songs were going to be regenerated again and again, so I designed the site around a fixed asset contract instead of a content pipeline.

The book’s content comes from three generators: illustrations from ChatGPT’s image model, recordings from Suno, and code from AI coding agents working to my architecture. The media would arrive named after song titles and be replaced many times. Numbered filenames survive a retitle and keep spaces and apostrophes out of URLs. The plate frame is exactly 2:3, so a 1024×1536 illustration drops in with nothing cropped. Every slot has a drawn placeholder or a greyed-out play seal, so the book could go live complete with no art or audio in it at all.

A small installer bridges each generator’s exports to that contract. It matches files to songs by the titles in the same data file the pages are built from, ignoring case and stray apostrophes. It skips an ambiguous match rather than guess, reads each file’s first bytes to flag a mislabelled format, and only ever reads the source folder, never writes to it.

  1. Repository created.
  2. First commit: the book running locally, every slot a placeholder.
  3. Already live after two deploys, with no art and no audio. A commit records the host’s 30-day cache lifetime, found on the live page (Case 4).
  4. A first batch of five recordings is dropped in. It is regenerated later that evening.
  5. The first of the five recordings the book still plays is generated.
  6. The first illustration is generated.
  7. Commit 6172fbb: the build still live on 12 Oct 2026.
Fig. 2 The first evening, 21 Sep 2026 (US Central time), from the repository’s creation time, its commit times and the media files’ creation times, on disk and embedded in the files. 30 of the repository’s 31 commits are from this day.
How we know

Read on 12 Oct 2026: the installer (scripts/install_assets.mjs) for the title matching, the ambiguous-match skip, the format check and the copy-only rule, and the placeholder and greyed-seal paths in js/book.js. All five illustrations measure 1024×1536 (sips) and carry C2PA content credentials naming ChatGPT’s image model; all five recordings are tagged as made with Suno. The plate frame measured exactly 1.5 (2:3) at 12 viewports on the live site.

Case 2 of 5

The book is data, and the engine outlived the book

No song is written into the HTML. One array drives every page, and that page structure outlived the book.

The page plan is six fixed pages plus two per song, so a sixth song would keep the spreads paired. Every URL hash has two destinations: in landscape it names a spread, in portrait a single page. A phone deep-linked to the index or the dedication lands on the content, not on the endpaper facing it (Fig. 6).

The lyrics are fourteeners, long lines that the page sets on two. Each sung line is split at its own caesura. A comma near the middle wins; otherwise the break goes at the nearest word gap that doesn’t strand a function word or a verb’s particle; and a hand-placed bar overrides both. Every type size and space on the page is a fraction of the live page width, so line length holds at every screen size with no type breakpoints at all, and a longer regenerated verse shrinks its own type instead of being clipped.

The website engine for small congregations that grew from this book kept the viewport cap, the back-turn repair, the deterministic page-jump routing, the page-width type scale and the deploy-time cache stamper.

Where the 40 sung lines break, from running the shipped splitter over the shipped lyrics on 12 Oct 2026
At a comma near the middle26
At the nearest safe word gap13
Placed by hand1
How we know

Read in js/book.js on 12 Oct 2026: the page plan, the per-orientation hash targets, the caesura splitter and the lyric fitter. css/book.css has 50 lengths relative to the page width, and its only width media query adjusts stage padding. On the live site at 390×844, deep links to #index and #dedication landed on those pages (Playwright). The derived engine was checked read-only for the parts it kept; it is a separate private project.

Case 3 of 5

Fixing a third-party library from the outside

The page curl comes from page-flip, vendored unmodified so the project has no dependency tree. Five of its behaviours were handled without forking it.

  1. A misspelt class. The library’s shipped stylesheet targets a class name with a typo, which collapses the book. The correction lives in the project’s own stylesheet, so a future upgrade can’t silently drop it.
  2. A backward turn that fakes a touch. It simulates a press at a hard-coded x coordinate. In portrait that point lands mid-book, and the turn is dropped with no error. The fix wraps the instance’s own methods rather than the buttons, which is what lets the repair reach the library’s swipe handler too.
  3. No way to fit a short window. The book’s height is derived from its width. A viewport cap, computed from the window alone, rewrites three inline styles the library owns.
  4. A “flip” event that doesn’t mean a flip. It means “page index published”: it also fires at start-up, on rotation and whenever the book asks it to re-measure after a change in window height, and an index link runs the arrival code twice, once from the library’s event and once from the page’s own hash handler. The play button’s spark was made idempotent per page. The music stop on the same event was not, so a change in window height still stops a playing song (see the honest notes).
  5. An animation that restarted mid-turn. The library hides offscreen pages with display:none, and a stylesheet animation restarts whenever its element is shown again. The play button’s spark now runs through the Web Animations API, which keeps its own timeline.
How we know

The vendored script matches page-flip 2.0.7 as published on unpkg by SHA-256, and its stylesheet by diff; the upstream stylesheet contains the misspelt class. Viewport fit was measured on the live site at 12 viewports from 360×640 to 1920×1080 on 12 Oct 2026: at 844×390 the page runs from 12 px to 378 px of 390, and only 360 px wide fails (see the honest notes). In a 390×844 portrait view, three presses of the back button each turned exactly one page.

Case 4 of 5

A deploy that can’t serve a stale page

The shared host sent CSS and JavaScript with a 30-day cache lifetime. I fixed it for new and returning visitors alike: every request now revalidates, and every deploy stamps the commit hash onto every script, stylesheet and icon URL.

Anyone who had opened the link would have been held on the first build for a month. The site’s core workflow, drop in an illustration or a recording and it appears, would have failed for them, with nothing on my side looking wrong. A corrected header only helps browsers that haven’t visited yet, because a cached copy’s own expiry is what governs it.

An .htaccess now makes every request revalidate, so a revisit costs a 304. The deploy copies the site to a staging directory and stamps the current commit hash onto each script, stylesheet and icon URL there (seven links in the HTML, plus an @import inside the stylesheet), so a returning browser asks for URLs it has never seen. The illustrations and recordings keep plain URLs, and the revalidation covers them. The working tree keeps plain links, so there is no version number to remember to bump.

The stamper asserts every link it expects to find and refuses to deploy otherwise. That assert caught a real mismatch the first time an icon was removed from the page but not from its list. The same script refuses on unexpected files in the publish folder, dotfiles included, and on a dirty tree, and it checks five live URLs for a 200 before it reports success.

The live site · 12 Oct 2026
$ curl -sI 'https://shout.mellette.net/css/book.css?v=6172fbb'
HTTP/2 200
cache-control: no-cache, must-revalidate
expires: Wed, 11 Nov 2026 18:28:08 GMT

$ curl -s -o /dev/null -w '%{http_code}' \
    -H 'If-None-Match: "6f8c-65c11c1b7c2b3"' \
    'https://shout.mellette.net/css/book.css?v=6172fbb'
304

$ curl -s https://shout.mellette.net/ | grep -o '?v=[0-9a-f]*' | sort | uniq -c
   7 ?v=6172fbb
Fig. 3 Re-run against the live site on 12 Oct 2026, output trimmed to the relevant headers. The host sets expires to 30 days after each request; browsers ignore it while Cache-Control is present. The eighth stamp is the @import inside the stylesheet.
How we know

The curl runs in Fig. 3, plus the same no-cache header on the live script, an illustration and a recording. scripts/deploy.sh and scripts/stamp_assets.mjs, read on 12 Oct 2026: 7 asserted links in the HTML plus 1 @import, two active refusals, and five post-deploy URL checks. The original 30-day lifetime (2,592,000 seconds) is recorded in the repository’s history.

Case 5 of 5

Audio that fades without clicking

Every start and stop fades over 600 ms, scheduled on the audio clock rather than stepped from script, so no fade crackles.

The play button and every page turn share one fade, and since each spread holds a single song, moving between songs always goes through a faded turn. A first attempt, never committed, stepped the element’s volume on every animation frame. It crackled even though its curve and length were right: each write is a step change in loudness applied at a buffer boundary, about thirty audible discontinuities per fade (zipper noise). The shipped version routes each track through a Web Audio gain node and schedules an exponential ramp on the audio clock, so the engine smooths it sample by sample.

The engineering is in the edge cases:

  • The audio context is created on the first press, because a context starts suspended until a user gesture.
  • A media element can be wired into the audio graph only once, so if that fails the song falls back to a clean stop instead of becoming a song that can never play again.
  • Interrupting a fade freezes the volume where it is, instead of snapping it back to full.
  • Pressing play on a song that is still fading out brings it back from wherever it had got to.
var now = audioCtx.currentTime;
gain.gain.cancelScheduledValues(now);
gain.gain.setValueAtTime(gain.gain.value, now);
gain.gain.exponentialRampToValueAtTime(MIN_GAIN, now + FADE_MS / 1000);
Fig. 4 The fade-out, lines 394–397 of the served js/book.js (build 6172fbb). FADE_MS is 600, and MIN_GAIN is 0.0001 because an exponential ramp can’t reach zero; the pause does the rest. The page turn itself takes 800 ms.
How we know

The fade functions and the play handler in js/book.js, read on 12 Oct 2026. On the live site in headless Chromium the same day: after song 1 starts, the right arrow turns to song 2, song 1 is still fading 300 ms later, and all five players are stopped by 1.8 s. No more than one seal is ever shown as playing.