Your dog can learn to fear a recording of your voice
What I Built
I don't own a dog.
But roughly one dog in six gets genuinely distressed when it's left home alone, and while reading up for this challenge I found something I couldn't stop thinking about.
In 2021, a Finnish app called Digital Dogsitter was put through a proper trial. It listened for the dog crying, and when it heard it, played back a recording of the owner's voice. Across 40 dogs, total vocalisation dropped by 95.7% in two weeks.
So the mechanism isn't a hunch. It's published.
But the same literature carries a warning, and it's the reason this post is called what it's called. One clip, looped identically, can stop being comfort and become a cue: the sound that means you're gone. Hear it enough times and the dog starts to dread it. Something that predicts abandonment doesn't calm anyone down.
And in 2021 there was no way around that, because you could only ever play back what you'd already recorded.
In 2026 there is. So I built it.
Stay listens for your dog, waits for it to go quiet, then speaks to it in your voice. Different words every time, generated fresh, never a loop.
Then when you get home, it tells you what happened while you were out.
Most dog tech points one way, helping humans understand dogs. Stay points the other way: helping dogs hear their humans.
Demo
Live: https://stay-swart.vercel.app
No key, no microphone: https://stay-swart.vercel.app/demo
That second link is the one to click. Demo mode runs a real recording of a distressed dog through the real detector and answers it with real audio. No signup, no API key, no recording your voice. It takes about ten seconds.
Watch for the gap. Stay doesn't answer while the dog is barking. It waits for the barking to stop and speaks into the quiet. That's the most important decision in the whole thing, and it's the next section.
When the clip ends, press "Read this session back to me". That's Gemini writing the closing summary, running on my key, so you get the full thing without an account.
(One number changes in demo mode and it's stated on the page: the cooldown drops from 90 seconds to 20, because the clip is only 40 seconds long and at the real setting the loop can complete just once. Every other rule is untouched, especially the wait for quiet.)
Code
Repo: https://github.com/vighriday/stay-for-dogs
The detector's decision, in the audio worklet:
const loudEnough = !deaf && db > this.thresholdDb && ratio > this.minBandRatio;
const voiced = loudEnough ? this.periodicity() : 0;
const isNoise = loudEnough && voiced > this.minPeriodicity;
The rule that decides when to speak:
case S.UPSET: {
// Answer the quiet, not the barking.
if (this.quietRun >= this.quiet) {
this.port.postMessage({ type: "speak", trigger: "settled", ... });
break;
}
// But never ignore a dog that cannot settle on its own.
if (this.noiseRun >= this.ceiling) {
this.port.postMessage({ type: "speak", trigger: "ceiling", ... });
break;
}
}
And the constraints on every generated line:
- 4 to 12 words. Never longer.
- Never use any word from the banned list, in any form.
- No questions. A question makes a dog expect something to happen.
- Never reference leaving, returning at a specific time, doors, or going out.
- Never use exclamation marks. Excitement is the opposite of what is needed.
Every line comes back through server-side validation that rejects anything breaking those rules and regenerates. I don't trust the model to follow its own instructions.
How I Built It
The bit I'm proudest of: it answers the quiet, not the noise
If the voice arrives the instant the dog barks, you've built a machine that rewards barking. The dog learns that barking summons its human. You'd be training the exact behaviour you're trying to reduce.
So Stay marks the dog as upset, keeps listening, and only speaks after 2.5 seconds of quiet. It answers the calm.
With one exception. If the dog never settles, Stay speaks anyway after 20 seconds, because an inconsolable dog shouldn't be ignored for failing to hit a threshold. The timeline records which rule fired, so you can tell "your dog settled and was answered" from "your dog never settled and was answered anyway."
That's about fifteen lines of code, and it's the difference between a comfort device and a bark trainer.
Four things I got wrong
The first detector never fired at all.
I'd written the obvious thing: a sound counts if it's loud, sits in the 300 to 2500 Hz band where barks live, and holds for 400ms. I ran it against 40 seconds of a barking dog and got two log entries. Session started, session ended.
So I instrumented it instead of guessing:
frames 497 · loud 37 · in-band 174 · both 37
longest unbroken noisy run: 6 frames rule required: 10
Barking is impulsive. Bursts of 150 to 250ms with gaps between them. It never produces 400ms of continuous sound, so my rule was never going to fire on a real dog. I'd designed for a noise dogs don't make.
The fix was to count separate onsets in a sliding window: three onsets in 1.5 seconds catches barking. Whining and howling are the opposite problem, quiet but continuous, so an unbroken 1.2 second stretch counts too. Two different noises, two routes in. Dogs went to 5/5.
Then it fired on everything. Four of seven household clips triggered it. Every decent door slam.
A door slam is loud, sits in the same band as a bark, and is made of several separated transients. On loudness, frequency and repetition it genuinely is the same thing as barking, so no threshold separates them. I tried a refractory gap between onsets and got nowhere.
What separates them is something else: a bark is voiced. It has a pitch, the waveform repeats. A door slam is broadband with no periodicity at all. So I added an autocorrelation test across the lags a dog's fundamental sits in, and swept the threshold:
| threshold | dogs | false positives |
|---|---|---|
| 0.30 | 5/5 | 5/7 |
| 0.50 | 4/5 | 1/7 |
| 0.65 | 5/5 | 1/7 |
| 0.75 | 5/5 | 0/7 |
| 0.80 | 5/5 | 0/7 |
0.75 and 0.80 both came back clean, so I took 0.75 as the middle of the plateau rather than its edge. No model, no download, nothing leaves the device.
Then I was wrong a third time.
That 5/5 was measured on the same twelve clips I'd tuned against. So I went and found twenty-two more from a different source: whimpering, crying, yelping, plus household sounds the first set had none of, like conversation and a washing machine.
| Measured on | Tuned-on (12) | Held-out (22) |
|---|---|---|
| Dogs detected | 5/5 | 5/9 |
| False positives | 0/7 | 4/13 |
The perfect score was overfitting. On audio it had never met: 56% detection, 31% false alarms.
I could have shipped the first table. It was true, it was measured, and nobody would have checked. The second one describes what happens to somebody who isn't me, so that's the one on the site.
One of those false alarms is people talking, which I should have seen coming. Human speech is the most voiced sound there is and it sits right inside 300 to 2500 Hz. So a television left on will trigger Stay.
The obvious fix works and I rejected it. Raising the pitch floor from 140 Hz to 300 Hz (adult speech is 85 to 255 Hz, dogs sit above) removes the false alarm, but it costs a whining clip. Whining is the single most characteristic sound of separation distress. This app exists for dogs that whine when they're alone.
Missing a distressed dog is the product failing at its job. Speaking when it shouldn't is annoying, and capped at once per 90 seconds anyway. So 140 Hz ships, and the worse-looking table ships with it.
That's where signal processing runs out. It can tell you what shape a sound is, never what made it.
The fourth one I found by writing a test, not by running it.
With a few hours left I added a test suite, not for coverage, but because the rules sitting between a language model and an animal should be checkable rather than just written down.
The prompt had always said: never use any word from the banned list, in any form, including inside other words. The validator behind it matched \bwalk\b. I typed out what I assumed would be a boring assertion:
BLOCKED Time for a walk
PASSES We are going walking soon ← this would have been spoken aloud
PASSES She walked away
PASSES Two walks today
Walk is the most reactive word on that list, and three of its four forms went straight through to the speaker. The prompt promised one rule, the code enforced a narrower one, and nothing anywhere compared them.
The obvious fix is worse than the bug: match a bare prefix and car swallows carpet and careful, so the app can't say "careful now" to a dog. It matches a short set of real inflections instead, and there's a test asserting that a dog named Walker is still called by its own name.
The next test found another one. Questions were only caught by their punctuation, so "Are you alright in there" sailed through. Still a question to a dog, and a question makes a dog get up.
Every test is a regression for something that actually went wrong. No test framework: Node 22's built-in runner and native TypeScript stripping, so the project still has five dependencies.
Running an AudioWorklet in CI
The detector is written against the AudioWorklet API, so it could only ever run inside a page. That left the one component whose worst failure mode is silence with no automated check. And I already knew what that costs, because the first version detected nothing on a real barking dog and looked perfectly healthy doing it.
But the worklet is only a class. It touches three globals, so shimming those runs it unmodified under Node. The tests drive the exact file the browser loads, not a copy of it. Then the signals are generated in code, each shaped to isolate one rule:
| Signal | Asserts |
|---|---|
| Three voiced bursts | An episode opens |
| One burst, then two | It doesn't. A bang is an event, not a pattern |
| Four loud broadband transients | Still nothing. The door slam case |
| A sustained tone | Opens via the continuous route: whining, not barking |
| Noise that never stops | Silent throughout, then answers the quiet afterwards |
| 22s unbroken | Answered anyway, on the ceiling rule |
| A second episode inside 90s | Logged as held, not answered |
| Faint barking at two sensitivities | The slider really does move the operating point |
No browser, no audio files, nothing to license.
The last test is my favourite: it asserts that a speech-shaped signal still triggers the detector. That's the documented limitation written down as an assertion, so nobody can quietly "fix" the television problem without noticing the whining case went with it.
Building it taught me something too. My first synthetic "speech" was a bass-heavy tone and it didn't trigger, because a 300 Hz highpass strips a 150 Hz fundamental. Real speech carries its energy in the formants, inside the dog band. Modelling it wrong would have deleted the limitation from the test bench while leaving it in the product.
94 tests, green in CI on every push.
Making it work on a free ElevenLabs account
I have no budget for this, so I started by finding out what a free key can actually do. Less than the docs suggest:
TTS with a library voice → 402 "Free users cannot use library voices via the API"
Voice Design via the API → 403 "Creating a voice through the API is only available
on a paid plan"
But a voice you create yourself in the ElevenLabs dashboard is a personal voice, not a library one, and driving that through the API works fine. I confirmed it with a real synthesis before building anything on top.
So Stay asks your key what it's allowed to do:
const caps = await getCapabilities(key); // GET /v1/user/subscription
caps.canCreateVoice // ← can_use_instant_voice_cloning
- Paid key: record three minutes, clone it in-app.
- Free key: Stay writes the voice description for you, you paste it into Voice Lab, hand back the voice ID.
- No key: demo mode, everything pre-rendered.
Three complete paths instead of one crippled one. Stay runs on a free account, never asks you for money, and never stores your key.
Why the audio is made before it's needed
Generating a line and synthesising it after the dog goes quiet costs two to six seconds. The moment's gone and the app looks broken.
So at session start Stay writes ten lines, renders them all, and holds them as decoded buffers. When the dog settles, playback is instant. It refills in the background, and underneath sits a bank of twenty hand-written pre-rendered lines, so a rate limit or a dropped connection never leaves the app silent.
Where it runs
Detection runs in an AudioWorklet on the audio thread, with every decision timed by counting samples rather than reading a clock. Away Mode means an unattended tab for hours, and browsers throttle timers and animation frames in background tabs. A render-loop detector would quietly stop working in exactly the situation the product exists for.
The number I never show the model
A session leaves behind something a camera can't produce: a record of a dog that was alone. When each upset started, how long it ran, how loud it peaked. That's what an owner actually wants, and as a list of timestamps it's unreadable. So Gemini writes the summary.
My first attempt handed it the figures with a prompt saying don't recite these, say what they mean. It recited them anyway, every time:
Biscuit got upset 3 times during the 42.5 minute session. The upsets lasted 14.2, 9.6 and 4.8 seconds, with peak volumes of -19 dBFS, -23 dBFS and -28 dBFS.
That's the table read aloud, in units no dog owner should ever see. I rewrote the prompt twice, harder each time, and it kept happening. Telling a model to ignore what's in front of it isn't a design. It's a wish.
So I stopped sending numbers. A pure function turns the timestamps into statements that are already true, and those sentences are the entire prompt input:
The session lasted about 45 minutes.
The dog got upset three times.
Each upset was shorter than the one before it.
The upsets also got quieter as the session went on.
There was one long stretch of about 20 minutes with nothing at all.
Stay answered every upset.
Every answer came after the noise had already stopped, never during it.
Which comes back as:
Three upsets, getting shorter and quieter Biscuit got upset three times during the session, which lasted about 45 minutes. Each upset became shorter and quieter as the session went on, and there was a long, quiet stretch in the middle. Stay answered every upset, always after the noise had already stopped. Worth knowing: The upsets did get shorter and quieter, but three of them is a thin basis for believing that means very much yet.
A model can't misreport a number it was never shown. And whether a direction can be described at all is decided in code from the episode count. Under three upsets, the prompt is told outright that no trend exists. The model can't be talked past that, because it isn't in the prompt. The page shows the exact input under a disclosure, so you can check rather than take my word for it.
It also caught a bug in my own code. Loudness direction was first computed as a ratio of dBFS values. Decibels are logarithmic and negative, so -28 / -19 isn't a ratio of anything, and a dog that was getting quieter got reported as getting louder. It's a difference test with a 4 dB floor now. Honesty scaffolding is only worth having if the numbers underneath it are right.
Prize Categories
ElevenLabs and Google AI.
ElevenLabs
The voice isn't a feature of Stay, it's the reason it exists. The whole premise is that the dog hears its own person saying something it has never said before.
- Subscription introspection (
/v1/user/subscription) picks which of three onboarding paths you get, before you hit a wall. - Instant Voice Cloning for paid keys.
- A guided Voice Design flow for free keys. Stay writes the description, and the trailing clause "speaking slowly and gently, as if calming an animal" makes a real difference to what comes back.
- Verification by actual synthesis. Pasting a voice ID doesn't just check it exists. Stay speaks two words with it and plays them to you, because a free plan can read the whole voice library and still get a 402 when it tries to speak.
- Delivery matched to the moment. The three moments Stay speaks into aren't the same moment, and a person's voice wouldn't be either.
| Moment | stability | speed |
|---|---|---|
calm, nothing is wrong | 0.6 | 0.95 |
settle, the dog just went quiet | 0.7 | 0.88 |
reassure, 20s of distress with no sign of stopping | 0.8 | 0.85 |
Higher stability means less expressive variation, which is normally a cost and is exactly the goal when the listener is an animal deciding whether the room is safe.
Google AI
Gemini does four jobs, and the fourth is the one I'd point at first:
- Reads the session back to you, the summary described above, where the model is never shown a number and so can't misreport one. It runs on the shared key, so you can try it in demo mode with no account.
- Writes every line, under hard constraints, with server-side validation that rejects and regenerates anything breaking them.
- Classifies vocalisation from audio. Whining and howling are distress; a short burst of sharp barks usually means something walked past the window. Different problems, different responses.
- Scores a clip of your dog alone: how much of it was spent pacing, how often it went to the door, how long until it lay down and stayed down.
Same pattern in all four: JSON schema mode, a prompt that defines every term exactly, and a deterministic check in code between the model and anything that reaches you or the dog. Gemini writes the sentences. It never gets to decide the facts.
Being straight with you
- This is a prototype, not a treatment. A badly anxious dog needs a veterinary behaviourist.
- I don't own a dog. So rather than film one dog once and call it proof, I published the rates and shipped all 34 clips in the repo so you can re-run the sweep yourself at https://stay-swart.vercel.app/test.
- Held-out performance is 56% detection and 31% false alarms, not the 5/5 I got on the set I tuned against.
- A television will trigger it. Human speech is voiced and in-band, and this detector can't tell a person from a dog.
- 34 clips is still small, and every clip is a clean recording of one thing. Real rooms layer sounds. A dog whining over a washing machine isn't represented at all.
- There's no room in the measurement. Clips go straight into the audio graph: no speaker, no microphone, no distance, no reverb.
- There's no before-and-after demo, and there won't be a fake one. A real comparison needs the same dog in two states, and putting two unrelated dogs side by side to imply a result would be a lie.
- The session summary is narration, not analysis. A handful of upsets in one afternoon can't tell you whether anything is working, and the summary says so out loud.
- The microphone audio never leaves your browser. Stay measures loudness and a frequency ratio on-device. Nothing is recorded, buffered or uploaded. That's a property of how it's built, not a promise I'm asking you to trust.
- This was built with AI assistance. Claude wrote the code. My part was the idea, the architecture and the direction: what to build, what to cut, what to ship, and what to own up to in this section. Research and sourcing came through Gemini and Perplexity.
What's next
Proper validation on real dogs, with owners who can run the before-and-after I can't. A bigger, more varied test set the detector has never seen. And an on-device audio classifier, which is where judging by periodicity runs out of road.
If you have a dog and twenty minutes, I'd genuinely like the footage.
Built 15 to 17 August 2026 for the DEV Weekend Challenge: Dog Days Edition. Every commit falls inside the challenge window, and the history is public and timestamped.