Abey Koolipurackal

Talking Machines

A year ago, I had an idea about meditation apps.

Most of them make you pick something from a library. That is a strange thing to ask when the reason you opened the app is that you are stressed, anxious, or otherwise not in a great state to browse. One person I interviewed told me she had opened Headspace during a panic attack and ended up using a sleep meditation because it was the closest thing she could find.

I wanted to remove that step. You would talk to the app for a minute, it would understand what was going on, and it would make a meditation for that moment.

I was really testing two things. Could I build the voice system cheaply enough, and would a personalized meditation make people come back?

After a year, the voice system worked. The beta still had not shown that personalization made people return.

Voice is awkward when it’s slow

In a text app, a typing indicator buys you a few seconds. In a voice app, the user hears nothing and assumes it broke.

My first version followed the obvious sequence. Speech recognition came first, then a language model, then text-to-speech. One turn took 10.7 seconds. You would finish talking and sit there wondering whether the app had frozen.

So I interviewed 20 potential users and paid attention to what bothered them. I expected everyone to complain about the wait. They mostly did not. They complained when the voice sounded robotic, or when its tone changed halfway through a meditation.

That changed what I worked on. I still wanted the conversation to be faster, but I was willing to accept a slower meditation if it sounded like one person speaking continuously.

Fig. 01What one turn felt likeamplitude · time →
A voice turn with a two second pauseThe user speaks, the app waits two seconds to detect the end of the turn, and Ember replies. USER EMBER REPLIES 2.0 S OF SILENCE
Figure 1. The user stops speaking. Sage waits two seconds to decide the turn is over, then starts the reply.

I built both versions

On Sage, most architectural choices were cheap enough to test directly.

I used that rule a lot on Sage. I built a real-time voice-to-voice version and a normal REST version. I tried server-side Whisper and Apple’s on-device speech recognition. I compared larger and smaller language models. I generated speech in one call and in several parallel calls. I tried letting the server turn off when idle, then tried keeping one machine running.

For each decision, I wrote down what I tried, what I measured, what the chosen version made worse, and what would make me reconsider it. This was mostly a defense against forgetting why I had made a shortcut six months earlier.

Transcription was a good example. Server-side Whisper added one to three seconds for upload and decoding, cost money per minute, and meant sending raw audio off the phone. Apple’s recognizer streamed partial text on the device, so only text went to the server.

I used Apple’s recognizer.

It heard “Ember,” the name of the guide, as “amber” for weeks. I added contextual hints and eventually a regex. The commit was can transcribe ember, made at 5:32 in the morning. It was an ugly fix, but I preferred that problem to slower turns and uploading everyone’s audio.

Transport was similar. The real-time prototype could answer in about a second, but it cost roughly ten times more per minute and came with WebRTC, reconnects, and a young API surface. The REST version was slower, but I could debug it with curl after midnight.

I shipped REST for the beta. I planned to revisit it if people started paying.

Fig. 02One turn through the pipelineΣ 0.00 s

I timed every step

At one point I made a commit called worse models, faster response. In the same change, I added timers around every await in the request path.

The measurements made the next few decisions easy. Authentication, network overhead, and audit writes were small. The language model and speech generation were taking almost all the time.

The biggest improvement came from speech generation. Generating a full reply in one call took about 4.5 seconds. I split the text at punctuation and generated three pieces at the same time. That brought the wall-clock time down to about 1.6 seconds.

With a smaller language model and on-device transcription, a full turn went from 10.7 seconds to 5.5.

Fig. 03The turn budget, three generationswall-clock seconds

Then people listened to it.

The faster voice sounded worse

Each speech request made slightly different choices about emotion and pacing. When I generated three pieces separately, the second piece sometimes sounded like a different take from the first. The joins also landed near the long pauses that make a meditation feel calm, and occasionally clipped them.

The change cut almost three seconds, but when I listened in headphones, the seams were obvious.

So I kept two paths. Normal conversation used parallel speech generation because a quick response mattered there. The meditation itself used one continuous request because the pauses and tone mattered more.

Until then, I had used the same speech path everywhere. I replaced it with two paths -- chunked audio for the back-and-forth and one uninterrupted synthesis call for the meditation.

A $3.19 server fixed a 12-second wait

Sage ran on one small virtual machine. The first user on some mornings still had to wait 12 seconds.

The machine was configured to shut down when nobody was using it. The first request then had to boot the VM, import the app, and initialize the API clients before doing any real work.

I first tried pinging the server on a schedule. Sometimes the pinger arrived too late and the machine had already shut down. I also considered putting warm-state information in Redis, which would have added another service without making the machine start faster.

The actual fix was setting min_machines_running from zero to one. It cost $3.19 a month.

Fig. 05The $3.19 fix24 h · one tick = one request

Keeping the process alive exposed another bug. One of the opening prompts was chosen randomly when the Python module loaded. When the machine restarted often, the opener changed often enough to look random. Once the same process stayed up for days, every user got the same opener.

I also managed to make the app talk to itself. The microphone would hear Ember’s generated voice, transcribe it, and send it back as a new user message. The app could continue the conversation indefinitely without a person in it.

I looked at echo cancellation and push-to-talk. Echo cancellation was finicky, and push-to-talk made the experience feel like a walkie-talkie. For the beta, I made the system half-duplex; the microphone stayed closed while Ember spoke, and users could tap to interrupt.

It was not as slick as full barge-in, but it stopped the app from having a conversation with itself.

I was changing the prompt every day

Ember’s behavior lived in a versioned prompt file, but it was not just a personality description. It moved through three phases. First it asked what was going on. Then it handed off at exactly the right point in the UI. Finally, it guided a meditation while tracking how far through it the user was.

Chat and meditation also needed different speaking instructions. The server had to tolerate old clients sending message roles the newest prompt no longer expected.

I changed the prompt almost every day and tested it by replaying real conversations. Feedback was stored next to the transcript that caused it. We ran more than 150 beta tests this way.

The manual loop improved the product, but it was slow. One user complaint led to the commit remove condesending tone. A small fixed test set with scores for tone, length, phase transitions, safety, and speech quality would probably have caught that earlier.

I designed that test set and never finished wiring it into the development loop. I do not want to rewrite that history as “we built an eval system.” We had transcripts, user feedback, and a repeatable manual review process. The automated part was still unfinished.

Then I checked whether people came back

By day 14, about 10 percent of the beta users were still active.

The group was small, so 10 percent was not a precise estimate. But it was enough to tell me that I had not obviously beaten the usual retention for meditation apps. Personalization might have helped some people, but the curve did not show a large change in behavior.

Fig. 06The curve that ended it% active · days 0–14

This was frustrating because the product basically worked. You could talk to it. It made a meditation about what you had said. The delay was manageable, the voice was decent, and the server bill was tiny.

That was enough for a demo, but it did not answer the question I cared about. Did personalization make people return more often than a library of recordings? The beta had not shown that.

The next version would have required real memory, better reminders, and much more work on habit formation. Around the same time, a large meditation company launched its own AI companion. That did not make Sage impossible, but it made the next year of work less attractive.

I shut it down.

The test I wish I had run

The retention curve told me people were not returning in large numbers. It did not tell me whether the missing piece was memory, the meditation quality, reminders, or something else.

The most useful next test would have been about memory.

My hypothesis would be simple. More users will return two weeks later when the app opens by mentioning a specific detail from their previous session.

Half the users would get the normal generic opening. The other half would get an opening based on the last conversation. The rest of the product would stay the same.

The main number would be day-14 retention. I would also watch first-session completion, how long it took people to return, negative feedback, safety issues, and cost per retained user. Before starting, I would choose the minimum improvement worth pursuing. I probably would have used three percentage points, then calculated how many users the test needed.

If memory did not clear that bar, I would stop treating memory as the main reason this product could win. If it did, I would spend the next cycle improving what the app remembered and how it brought those details back.

I wish I had run this before making broader conclusions from the beta curve.

What I would keep

A lot of the code I wrote is already less useful than it was. Newer real-time speech models can handle transcription, turn-taking, generation, and speech in one system. I would not rebuild the endpointing logic, the speech stitching, or most of the echo handling today.

What still feels useful is how I made the decisions. When two approaches were cheap to prototype, I built both. When a request was slow, I measured every step instead of guessing. When users disliked the faster version, I kept the slower one where it mattered. When the retention result was weak, I did not turn it into a success story.

I was happy with the engineering. I still did not know whether personalization was enough to make people keep using the product.

back