← All work
PAUSED — CORE RISK SOLVED

SteTalk

The phone that translates: you speak Serbian, they hear English. Working prototype on real hardware — translated voice in about 2 seconds.

Product 2026 React Native (Expo)Fastify + PostgreSQLLiveKitAzure SpeechClaude Haiku

On July 12, 2026, I spoke Serbian into a phone — and the other side of the call heard only English. No original voice, no recording, no interpreter.

Context

SteTalk is “the phone that translates”: 1-on-1 calls where each side speaks their own language and hears only the other person’s translated voice. First pair: Serbian ↔ English, built for business calls and mixed-language couples. The name comes from Stefan (me) and Stela (my daughter) — we both carry the “Ste.” Tagline: “Speak to everyone.”

Decisions

  • Privacy as architecture, not policy. Conversations are never recorded — audio flows through translation and disappears, and TTS is synthesized straight into memory. Simpler GDPR, and “we can’t leak your calls” beats “we promise not to.”
  • Fair billing as a launch condition. An immutable, append-only transaction ledger enforced by database triggers, with server-authoritative call endings. A prepaid product that overcharges once is finished.
  • You hear only the translation. The app never even subscribes to the other side’s raw audio, and the server enforces the same rule again at the media layer. Defense in depth for the core promise.
  • Numbers first, pricing second. We measured the real cost per billed minute — and the breakeven point — before setting the prepaid price, not after. A prepaid product priced on hope is a refund queue waiting to happen.

Build

I don’t write code — I direct AI agents, spec-first. Every choice lands in a decision book (27 product decisions), and every segment ships only after a fresh-context audit reaches zero blockers. Stack: React Native/Expo, Fastify + PostgreSQL, LiveKit for calls, Azure STT/TTS, Claude Haiku for translation. 72 commits in 4 days (July 10–13, 2026). Along the way we isolated a byteOffset bug in LiveKit’s rtc-node SDK — the full debugging story is at the bottom of this page.

Outcome

The hardest technical risk — live translated speech inside a real call — is solved and proven end-to-end on real hardware. Streaming in every step of the pipeline cut time-to-audio to about 1.2–2.4 seconds for short phrases. What remains is operational: push notifications, monetization wiring, app-store packaging, trademark filing — one-time investments. Pausing here is a resource-allocation decision, not a technical wall.

Lesson

Teaching a phone call to swap languages took four days; deciding when to pay the trademark lawyer is the part that actually needs a calendar.


Build log — The bug that sounded like silence

Engineering story · · by Stefan Ćirović

Building this — two people speaking different languages, each hearing only the translated voice of the other — my AI agents and I hit a bug that cost us two days. Not because it was complicated. Because it was perfectly camouflaged.

The symptom

The translation pipeline worked end to end: speech recognition heard the Serbian, the model translated it, text-to-speech generated clean English audio. We could verify every step. But on the other phone: silence. Whole phrases arrived as nothing.

Except our test tone worked. A 440 Hz sine wave, pushed through the exact same audio path, played beautifully on the receiving end. So the pipeline was fine, right? The bug had to be on the subscriber side. We spent two days on that theory.

The trap

Here’s what we were doing — pushing 24 kHz PCM from TTS into LiveKit’s AudioSource in 100 ms chunks:

for (let offset = 0; offset < pcm.length; offset += CHUNK) {
  const chunk = pcm.subarray(offset, offset + CHUNK); // views into one big buffer
  await source.captureFrame(new AudioFrame(chunk, 24000, 1, chunk.length));
}

And here’s the relevant line inside @livekit/rtc-node (v0.13.31, audio_frame.ts):

dataPtr: FfiClient.instance.retrievePtr(new Uint8Array(this.data.buffer)),

new Uint8Array(this.data.buffer) wraps the entire underlying buffer — ignoring the view’s byteOffset. So every chunk we sent was silently replaced by the first chunk of the buffer. The frame length was right; the content was always chunk #1.

Now the beautiful part. Why did the sine tone work? At 24 kHz, a 100 ms chunk of a 440 Hz sine contains exactly 44 full cycles. Every chunk of a pure sine is byte-identical to the first one. Our test signal was precisely the kind of waveform this bug cannot break. And TTS audio? It starts with near-silence — so every phrase played as its own silent opening, over and over.

The test wasn’t failing us. It was lying to us.

The catch

We stopped theorizing and built the dumbest possible experiment: fill half a buffer with silence, half with a loud DC value, send the loud half as a subarray() view, and measure RMS on the receiving end.

  • With subarray() (a view): RMS = 0. Silence.
  • With slice() (a copy of identical data): RMS ≈ 3400. Loud and clear.

One measurement, no ambiguity. The workaround is a one-word change (slice instead of subarray), and the upstream fix is one line:

new Uint8Array(this.data.buffer, this.data.byteOffset, this.data.byteLength)

As of July 2026, the bug is still present in the latest release — we’re filing an issue with a minimal repro (linked here once it’s up).

The lesson

A test that passes for the wrong reason is worse than a test that fails. If we had picked any test signal that changes from chunk to chunk — a frequency sweep, a voice clip — we’d have caught this in an hour. SteTalk’s audio tests now use signals that would notice being replaced by their own first chunk.

And a note on method: I don’t write code — I direct AI agents that do. Two days of wrong theories ended the moment we switched from reasoning about the bug to measuring it. That rule — when stuck, stop arguing and build the isolating experiment — is now written into how every one of my projects runs.