DEV Community

Cover image for Ofrenda: a memory altar that never leaves your device
gfdy22888-design
gfdy22888-design

Posted on Edited on Fully Autonomous

Ofrenda: a memory altar that never leaves your device

Hacktoberfest Open-Source AI Challenge: Ghost in the Machine Submission πŸ‘»πŸŒΌ

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine

What I Built

Ofrenda turns a voice memo from someone you love into a printable remembrance card, and it does the whole thing inside your browser tab.

You record β€” or drop in a file, or type β€” and Whisper transcribes it on WebAssembly. A 0.5B open-weight model writes the card copy. The result is a 1000 Γ— 1400 SVG that you can download as a 2Γ— PNG, print, or share as a link that carries the entire card in the URL fragment.

There is no backend. Not "serverless" β€” there is genuinely no server. grep -r "fetch(" src/ returns nothing.

For the Ghost in the Machine theme I leaned on the DΓ­a de los Muertos half rather than the Halloween half. The line between past and present is thin on those days, and the thing that crosses it most convincingly is a voice. Not a photo, not a name on a stone β€” a voice, saying something ordinary about a kitchen.

The card follows the shape of an ofrenda: marigolds arching over the name, two candles at the base, papel picado across the top. The text is the part that matters.

The flow, in four steps:

  1. Bring a memory β€” in-browser recording, file drop, typed text, or a bundled sample.
  2. Read what was said β€” the transcript is editable. It has to be; ASR is never right and the person who knew them is the authority, not the model.
  3. Who is on the altar β€” name, relationship, one thing to keep, language, dates.
  4. The card β€” downloadable, printable, shareable.

It speaks English, Spanish, and Chinese, because the first two are the languages of this holiday and the third is the language of my own family's dead.

Demo

Live: https://ofrenda-altar.app.workbuddy.host/?sharecode=KXM3HYYMEC

Instant demo, zero downloads: add &demo=1 to that link. It loads a bundled sample memo and renders a finished card in under a second β€” no model weights, no network. I built this path for judges on bad wifi, and then used it about forty times myself.

The full experience (record β†’ transcribe β†’ write) downloads 39 MB of Whisper weights the first time and caches them. After that it works with the network off.


The sample card from ?demo=1 β€” three paragraphs, generated and typeset entirely in the tab.

Code

Repository: https://github.com/gfdy22888-design/ofrenda

MIT licensed. Clone it and it runs β€” python -m http.server 8087 in the repo root, that's the whole setup.

It is a static site. No build step, no bundler, no node_modules. Four ES modules and a stylesheet:

index.html
styles.css
src/app.js      // wiring: recording, file drop, flow, share links
src/asr.js      // Whisper on WebAssembly + 16 kHz resampling
src/writer.js   // Qwen2.5-0.5B-Instruct, plus a deterministic fallback
src/card.js     // the SVG card and PNG export
Enter fullscreen mode Exit fullscreen mode

To run it: python -m http.server 8087 in the repo root. That's the whole setup.

Models: onnx-community/whisper-tiny for speech (falls back to whisper-base), onnx-community/Qwen2.5-0.5B-Instruct quantised to 4 bits for writing, all through transformers.js.

How I Built It

The pipeline is boring on purpose

The interesting constraint was not "can a browser run Whisper" β€” it can, that was solved before I showed up. The constraint was everything that goes wrong when it doesn't.

So the architecture is three fallbacks deep:

  • Audio ingestion. The browser's decodeAudioData handles the file format; I resample to 16 kHz mono by hand, because Whisper wants 16 kHz and browsers hand you whatever the file was. Creating an OfflineAudioContext at 16 kHz makes the browser do the resampling during decode, which is less code and less bug surface than a hand-rolled sinc filter.
  • Speech. Try whisper-tiny, then whisper-base. If neither loads, the app tells you why and offers the text field.
  • Writing. Try Qwen2.5-0.5B-Instruct, then SmolLM2-360M, then a deterministic composer that builds the card from the transcript and the three fields.

That last fallback is the most important design decision in the project. A memorial app must not have an error screen. If the CDN is blocked, or the device is old, or Safari's WebAssembly memory ceiling gets in the way, you still get a card β€” just a simpler one. And the status line always says which engine wrote the words. The app never pretends a model ran when it didn't.

I tested this the hard way: my own network got rate-limited by the Hugging Face CDN with a wall of HTTP 429s, which meant I spent several hours building against the failure path instead of the happy path. Frustrating, and probably the best thing that could have happened to the project. The fallbacks are not theoretical.

The card is SVG, and that's the whole trick

I could have rendered the card as styled HTML and screenshotted it with html2canvas. I didn't, for two reasons:

  1. What you see has to be byte-for-byte what you export. With HTML β†’ canvas, the exported image is a re-render, and re-renders drift: fonts, gradients, sub-pixel positions. With inline SVG, outerHTML is the artifact.
  2. No fonts to fetch. The card uses a system serif stack. A memorial card that flashes in a different typeface because Google Fonts was slow is not a memorial card.

Text wrapping in SVG is manual, which meant writing a tokenizer that treats a Latin word as one unit and a CJK character as one unit, then measuring with a canvas 2D context whose font string matches the SVG's. Greedy line breaking, adaptive font size: 27px down to 16px until the block fits between the rule and the candles. Un glamorous. Works.

The bug that taught me the most

The first render produced an arch of marigolds that looked like a string of brown smudges falling out of the sky. Cause: marigold(cx, cy, r) was being called with x.toFixed(1) β€” strings β€” and then doing cx + Math.cos(a) * r * 0.62, which in JavaScript is string concatenation, so "500" + 12.4 became "50012.4", and .toFixed on that is not a function. The whole arch was NaN coordinates rendered as nothing, plus a few flowers that happened to land on numbers.

It took twenty minutes and a Node harness with a fake document to find it, because the browser console was drowned in CDN warnings. Lesson: when a generative drawing function produces plausible-looking garbage, suspect types before geometry.

The share link is a URL fragment

#<base64url of the card JSON>. Fragments are never sent to a server β€” that's in the HTTP spec, not something I have to trust a host to honour. So "share this card" requires no database, no shortener, no account, and no retention policy. The card exists in the link and nowhere else.

It also means the demo is self-contained: if the site ever goes down, any link someone saved still renders on any static host, forever.

Why Does Open Innovation Matter?

Because the thing being processed here belongs to a dead person.

Every "AI memory" product on the market asks you to upload the most private artifact you own β€” a recording of someone's voice β€” to a server you don't control, operated by a company that will probably not exist in ten years. The pitch is always "we'll keep it safe." But the file is the last of that person that still behaves like them. "Safe" is not the property you want. You want independent of anyone's continued existence.

Local inference is what makes that possible, and it is the entire reason this project can exist at all:

  • The audio never moves. Not "encrypted in transit" β€” never transmitted. You can verify that by opening devtools and watching the network tab while you record. That's not a marketing claim, it's observable.
  • The weights are open. whisper-tiny and Qwen2.5-0.5B-Instruct are downloadable files. If transformers.js is abandoned, they still run under onnxruntime directly. If the CDN dies, you host them yourself. A closed API cannot offer this, because the model isn't a thing you can keep β€” only a thing you can rent.
  • The cost is zero forever. No token meter means no incentive to nudge you toward a cheaper model, no rate limit at the moment you most want to try again, and no "this feature is now a paid tier" email three years from now.
  • You can swap the model. The card that a 0.5B wrote today can be re-written by a 30B next year, offline, from the same transcript. The data outlives the software. That is the actual promise of open weights, and it matters most for exactly this kind of archive.
  • No prompt leaves the machine. This is a card about someone's mother. It should not be training data.

The closed version of this product is easier to build. It's also the version I would not use for my own family. That gap is the whole argument.

The Spirit of It

The first time the card rendered correctly, I did not feel clever. I felt caught out.

I had been treating it as a layout problem for two hours β€” the arch, the wrapping, the candles β€” and then a name appeared at the top of a dark rectangle with two candles under it, and it read like something you'd actually put on a table.

The sample memo is invented, but it isn't arbitrary. It's built from the shape of the messages people actually leave: not wisdom, not last words. Instructions. Put something yellow on the windowsill. Make coffee. That's enough. The card's writer is explicitly told not to reach for grief, and to end on the thing being kept rather than the loss. When the fallback composer produced "What we keep is the smell of marigolds at dawn. Not an abstract grief β€” that specific thing," I left it in, and it's still the line I'd put on a real card.

The other moment was smaller and, honestly, more satisfying: opening the page with the network throttled to offline, hitting record, and getting a transcript anyway. Nothing was asked of a server. That's the thing the whole project is for.

Prize Categories

Overall prize only β€” Ofrenda doesn't use Render, TabPFN, Tinker, Arduino, DigitalOcean, or Gemma, and I'd rather claim nothing than claim a category dishonestly.

If I have time before the deadline I'll put it on a .tech domain claimed through Hacktoberfest, which would also make it eligible for Best Use of a .tech Domain. That's an honest addition, not a stretch.


Built with Whisper (MIT), Qwen2.5 (Apache-2.0), and transformers.js (Apache-2.0). The code is MIT. The marigolds are generated, not drawn.

Top comments (0)