DEV Community

Cover image for The Waiting Room: I generated a voice for eight shelter dogs from their own paperwork
Abeera Lodhi
Abeera Lodhi

Posted on

The Waiting Room: I generated a voice for eight shelter dogs from their own paperwork

DEV Weekend Challenge: Dog Days Edition Submission πŸ•

This is a submission for Weekend Challenge: Dog Days Edition

"I really enjoy the company of my human friends. I may be shy at first, but once we get to know each other I will shower you with love! All I need is a soft warm place to lay, and to be told I am the goodest boy. I already know how to sit and I walk lovely on leash!"

That's Hart's entire adoption listing. Read it again and tell me one thing about him.

You can't. Not his age, not his size, not how long he has been sitting in a kennel. And here is the part that took me most of a day to notice: two of those four sentences are not about Hart at all. They also appear, word for word, in other dogs' listings at the same shelter.

Most listening projects would take that text and read it in a nicer voice. The Waiting Room reads it twice β€” once in the shelter's own upbeat register, and once in a voice generated from Hart's structured record β€” and the gap between the two is the whole argument.

What I Built

The Waiting Room is a single-page listening study of eight real, adoptable dogs. Every word on every card is verbatim from the shelter that published it. The only thing I added is the voice.

TL;DR

Eight real shelter listings, each read twice. The flat reading is a stock ElevenLabs voice. The second reading is a voice generated by ElevenLabs Voice Design from a prompt that a documented function computes out of that dog's record β€” age band, size, days waiting, returns, how it arrived. Flipping between the two readings preserves the playhead, so the same sentence changes voice mid-word. Not one word of the listing text is mine.


Demo

Live: https://waiting-room-ruby.vercel.app/

Repo: https://github.com/Abeera81/Waiting-room

60-second walkthrough β€” the audio swap is the thing to listen for.

The whole interaction fits in one screen:

The desk, with Yuji's record on top of the deck

One record at a time on a desk, with the rest of the deck showing underneath.

  1. Press play. You hear the listing in a flat, upbeat listing voice.
  2. Mid-sentence, hit DESIGNED. The words continue from exactly where they were, in a different voice.
  3. Open How this voice was derived to see which attribute produced which part of the prompt.
  4. On Yuji, drag the stay dial from Week 1 to Year 2 and listen to the same sentence get tired.
  5. Any sentence with an amber underline is one the shelter also gave to another dog β€” click a name to hear that dog read the same words.
  6. Arrow keys, drag, or the tabs at the bottom move through all eight records.

The finding: the template is louder than the dogs

I did not set out to write about templates. I set out to build a voice generator and needed eight real listings to feed it. Then I read them side by side.

Three of the eight dogs were given this sentence, character for character:

"I may be shy at first, but once we get to know each other I will shower you with love!"

Sterling is an 8-year-old Chihuahua. Ponyboy is a 3-year-old Anatolian Shepherd. Hart is a 9-year-old pit bull. One template, three animals with nothing in common but a shelter.

It isn't a one-off. The page detects the overlaps itself at load β€” exact string match after collapsing whitespace, six-word minimum, no fuzzy matching β€” and finds three shared sentences across five of the eight dogs:

Shared sentence Appears in
"I may be shy at first, but once we get to know each other I will shower you with love!" Sterling, Ponyboy, Hart
"I can be worried about new people, new surroundings and touch." Sun Bear, Sterling, Ponyboy
"All I need is a soft warm place to lay, and to be told I am the goodest boy." Ponyboy, Hart

Ponyboy comes off worst. Three of the four sentences in his listing belong to other dogs too.

Ponyboy's listing with three of four sentences underlined as shared

The marks are drawn from the detector, not typed by me. The underlined sentences are the ones that are not his.

I want to be fair to the shelters here: a template is a rational response to being understaffed. Oregon Humane is moving hundreds of animals with a handful of people. But the effect is that the document cannot tell a 3-year-old Anatolian Shepherd from a 9-year-old pit bull, and the document is what an adopter reads.

That is not a claim I have to argue. It's in the source material, and the page checks it in front of you.

The thesis

Adoption is an emotional decision made from a bureaucratic artifact.

The listing text is 100% the shelter's β€” no additions, no paraphrase, no trimming for taste. The shelters write in the dog's first person, cheerfully, and that cheerfulness ships exactly as published. The only thing I contribute is the voice.

So when a worn, slow voice reads "I will shower you with love!", nothing has been editorialised. The cheerfulness is the institution's. The weariness is the record's. Both were already in the document; they were just never audible at the same time.

The FLAT / DESIGNED switch in flat mode

The shelter's reading.

The same switch in designed mode, amber

The record's reading. Same words, same file length, same playhead.

Code

The Waiting Room

Shelter listings are documents. The Waiting Room makes them sound like the dogs they describe.

Built for the DEV Weekend Challenge: Dog Days Edition (Best Use of ElevenLabs) Repository created 2026-08-16, within the challenge window.

Run locally

node tools/serve.js 8124   # then open http://localhost:8124
Enter fullscreen mode Exit fullscreen mode

Use this rather than python -m http.server: Chrome's media stack issues ranged requests for audio and stalls against a server that ignores them.

Tests

npm test
Enter fullscreen mode Exit fullscreen mode

31 tests, no dependencies (node:test). They cover the mapping function, the shared-sentence detector, and the shipped data/dogs.json itself β€” that every audio file referenced exists, that all eight dogs derive distinct prompts, that no slider stop duplicates its neighbour, and that no placeholder text survived.

How it works

  • src/voicePrompt.js β€” the attribute β†’ prompt mapping. Pure function buildVoicePrompt(attributes) returns the string handed to ElevenLabs Voice Design; derive() returns the same mapping with every rule's…

No backend, no database, no build step. Four modules and a stylesheet: voicePrompt.js (the mapping), audioBus.js (the swap), sharedText.js (the template detector), app.js (the deck), and styles.css. Thirty-two tests under node --test, zero dependencies.

The swap is the demo

Two audio files, one <audio> element, and the playhead held across the source change. This is the moment I built first and everything else around:

// src/audioBus.js β€” the same sentence, in a different voice, mid-word
swapSource(src) {
  if (src === this.currentSrc) return;

  // Mid-drag the element has already been reset by an earlier swap, so trust
  // the held intent over what the element currently reports.
  const t = this.pending ? this.pending.t : this.position;
  const wasPlaying = this.pending ? this.pending.wasPlaying : this.playing;
  const token = ++this.swapToken;

  this.pending = { t, wasPlaying };
  this.currentSrc = src;
  this.el.src = src;

  const restore = () => {
    if (token !== this.swapToken) return; // a newer swap already won
    this.pending = null;
    this.el.currentTime = t;
    if (wasPlaying) this.play();
  };

  this.el.addEventListener('loadedmetadata', restore, { once: true });
  this.el.load();
}
Enter fullscreen mode Exit fullscreen mode

Measured seek error across three consecutive flat↔designed swaps: 0.000 s, about 35 ms to load.

The pending and swapToken bookkeeping is not defensive decoration β€” it's two real bugs. Assigning .src immediately resets currentTime to zero, so a naive version reads that zero when a second swap arrives before the first has loaded, and the playhead is gone. That is exactly what dragging the stay dial does. The token exists because a stale loadedmetadata handler from a superseded swap will happily rewind you to a position you left three swaps ago. (There was also a third bug, which was mine and not the browser's: a hand-built silent placeholder MP3 with its channel-mode bits in the wrong byte, undecodable in Chrome and perfectly decodable everywhere I tested first.)

The hero dog: one variable moved

Yuji is a senior American Staffordshire Terrier and Labrador mix at Better World Rescue. He was hit by a car and surrendered by his family. His listing says he has been waiting "way too long".

His card carries a stay dial with four stops. Every stop calls the same function on the same record, with days_in_shelter as the only thing that changes:

Stop days_in_shelter What the function adds
Week 1 3 (nothing)
Month 1 30 a little flat
Month 4 120 quiet and tired
Year 2 730 flat, barely lifting, worn through

Dragging the dial swaps the audio through the same playhead-preserving function, so the voice ages mid-sentence. Four stops, four different weariness bands β€” chosen so that no drag is silent, and a test fails loudly if anyone changes the stops in a way that makes two of them collide.

The stay dial at Year 2 with the derived prompt beside it

The readout updates from the function, not from a lookup table.

Read the label on that dial before you read anything into it. It is a demonstration of what the mapping does with one variable. It is not a claim about Yuji's actual stay. More on that below, because it turned out to be the most interesting thing I found.

How I Built It

This is the intellectual core, so here it is in full. Five rules, applied in order, concatenated:

BASE       age_band     puppy  β†’ "bright, very fast, unsteady, tumbling"
                        young  β†’ "quick, energetic, eager"
                        adult  β†’ "even, steady, measured"
                        senior β†’ "low, slow, weary, patient"
PITCH      size         large  β†’ "lower, resonant"     small β†’ "higher, clipped"
                        medium β†’ (nothing β€” the unmarked case)
WEARINESS  days         1-29   β†’ (nothing)             30-119  β†’ "a little flat"
                        120-179β†’ "quiet and tired"     180+    β†’ "flat, barely lifting, worn through"
GUARD      returns      1      β†’ "slightly hesitant"   2+      β†’ "anxious, over-eager, trying too hard"
STRAIN     intake       stray  β†’ "watchful"            owner_surrender β†’ "resigned"
                        returned β†’ "uncertain"         unknown β†’ (nothing)
Enter fullscreen mode Exit fullscreen mode

It's a real function, not a hardcoded lookup β€” derive() returns the same mapping with every rule's input and output, and the card renders that live:

// src/voicePrompt.js β€” pure, no I/O, no randomness, input not mutated
export function buildVoicePrompt(attributes) {
  return derive(attributes).fired.map((step) => step.value).join(', ');
}

export function derive(attributes) {
  const steps = RULES.map((rule) => {
    const input = attributes[rule.attribute];
    if (rule.required && (input === undefined || input === null)) {
      throw new Error(`buildVoicePrompt: attributes.${rule.attribute} is required`);
    }
    return { ...pick(rule, input), id: rule.id, label: rule.label,
             attribute: rule.attribute, input };
  });

  const fired = steps.filter((step) => step.fired);
  return { prompt: fired.map((s) => s.value).join(', '), steps, fired };
}
Enter fullscreen mode Exit fullscreen mode

Every card shows its own working, including the rules that fired nothing β€” because a rule that stays silent because the shelter published nothing is a different thing from a rule that had nothing to say, and the panel labels which is which:

The derivation panel showing five rules and the final prompt

The record on the left, the prompt on the right, no step hidden.

Eight records in, eight distinct prompts out:

Dog Record Generated prompt
Yuji senior, large, owner surrender low, slow, weary, patient, lower, resonant, resigned
Paddington adult, large, returned once even, steady, measured, lower, resonant, slightly hesitant, uncertain
Sun Bear puppy (3 mo), medium bright, very fast, unsteady, tumbling
Sterling senior, small low, slow, weary, patient, higher, clipped
Buck young, small quick, energetic, eager, higher, clipped
Moose young, large quick, energetic, eager, lower, resonant
Ponyboy adult, large even, steady, measured, lower, resonant
Hart senior, large low, slow, weary, patient, lower, resonant

Sterling and Hart are both seniors. One is a Chihuahua and one is a pit bull, and the size rule is the only reason they don't sound the same. The shelter's copy could not tell them apart. The record could.

Why Voice Design, and not a preset voice with tuned parameters

Picking a preset is choosing from a menu. Voice Design generates a novel voice from a text description β€” which means the record can produce the voice, because the record can produce the description. That's the entire mechanism, and it's why this is a Voice Design project rather than a TTS project.

Now let me name the control honestly, because the obvious objection is a good one.

The flat reading is not a monotone robot. It's ElevenLabs' stock "Roger" β€” their own description is "Charismatic, positive and conversational" β€” on Eleven Multilingual v2, the same model as every designed voice. I deliberately did not sandbag the control. Roger is the upbeat register that shelter marketing is already written in, which makes the comparison one of tone, not expressiveness. If I'd used a flat robotic preset, the demo would be more dramatic and would prove nothing.

Nineteen clips, all generated on the free tier, which ended with 116 credits to spare.

Why the audio is pre-generated

Every clip is committed to the repo as an MP3. The browser makes no API calls and the repo holds no keys.

Three reasons, in order of how much I care: a judge opening this in six months hears exactly what I heard; there is no key to leak in a public repo; and no rate limit can decide whether my submission works during judging. The cost is that the demo can't voice arbitrary new text β€” which is the correct trade for a piece whose entire point is eight specific records.

The variable I could not get

Here is the part I did not expect.

No shelter publishes an intake date. Not one of the eight listings states how long the animal has been waiting. Not one shelter page, not the Petfinder record, nowhere. days_in_shelter is "unknown" for all eight dogs.

I could have estimated. It would have been easy and nobody would have checked. Instead unknown became a real value in the mapping β€” valid input, contributes nothing β€” and a genuinely missing value still throws, because "the shelter didn't say" and "I forgot to fill this in" must not look the same in a system that claims to derive things from records.

The consequence is uncomfortable and I think it's the most honest thing on the page: not one of the eight dogs carries a weariness modifier in its own voice. Weariness exists in exactly one place β€” Yuji's dial β€” where it is explicitly a demonstration of the function rather than a report about an animal.

So the honest sentence is: we can hear what waiting does to a voice; we cannot tell you how long any of these dogs has actually waited.

The absence is the finding. The one number that would turn this from an interpretation into a measurement is the one number the sector does not publish. Yuji's listing says "way too long". That is as precise as the record ever gets.

Limitations, plainly

  • The voices are an interpretation, not the dog. Nobody at these shelters signed off on how their animals sound here.
  • The mapping is authored, not learned. It encodes my reading of what a record implies about a voice. A different designer would write a different table and get different dogs.
  • Eight dogs is not a study. Three shared sentences across five records is a real, checkable observation about eight listings, and nothing more.
  • The degradation curve is a design choice. The four weariness bands are chosen, not measured. No one has established what 120 days does to anything.
  • The waveform is decorative. It's a progress bar drawn deterministically from the dog's id, not an analysis of the audio, and the page never claims otherwise.
  • These dogs may already be adopted. I hope so. Every card links to its source, captured 2026-08-16.

Prize Categories

Best Use of ElevenLabs

The API is not narrating this project β€” it's the visualisation layer. A documented pure function turns structured shelter data into a Voice Design prompt, and the generated voice is the output of that function, shown with its working on every card. The same function called four times with one variable moved produces the hero's stay dial, so the "one variable" claim holds by construction rather than by assertion. Nineteen clips, one stock control voice named openly, thirty-two tests, no keys, no backend, no live calls.

Built for the DEV Weekend Challenge: Dog Days Edition β€” repo created 2026-08-16, deployed and written inside the window, with Claude Code as co-author.

Eight dogs. One template. Two voices each. Go listen to Ponyboy, then click through to Hart and hear the same sentence again.

Top comments (0)