~14 min · reliability, routing, exactly-once, debugging
Level 0Muted
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"voice mode: a recording is never held silently" — the commit that fixed it, 2026-09-25
Fifteen Seconds, Transcribed, Kept, and Never Sent
Within the first hour of voice mode, Dad recorded 15.6 seconds of Korean in the WebUI. The recording was transcribed and kept. No turn followed. He asked whether the send guard had eaten it. It hadn't: that guard only arms the composer's Enter key, and a voice send never passes through it. The same build with a fake microphone sent every time, fresh or after a wait. So the code had to contain a way to hold a recording without a word, and it contained two. If a reply was still streaming, the words went into the composer and sat there. And the send function silently dropped any send it refused because another was already in flight.
Three Outcomes, No Fourth
The fix names every place a finished recording can go, and there are exactly three. Send it now. Queue it behind a reply that is still streaming, and send it the moment that stream ends; a second recording that arrives meanwhile joins the first, so two breaths become one turn. Or put it in the composer, which happens only when Confirm is on or voice mode is off, because outside voice mode the microphones never send on their own. Whether a send is possible is asked first, and a send that fails to start lands in the composer with a note instead of vanishing. What gets placed is the turn: the words, with the recording's upload id beside them when that upload succeeded (Track 2's last lesson covers the case where it didn't). The controls say why a recording is waiting: "Confirm is on", or "Queued". Even the Confirm toggle was redrawn to read like the Voice toggle, filled meaning on, because its old outlined look read as off.
One more rule protects Dad's words from the soul's voice. From the moment he starts recording until his words are sent or placed, any unit of the reply that completes is passed over, never started. His talking silences the soul.
The Phone's Version: Exactly Once, Never Late
The phone lost replies in the opposite direction: two recordings went out, both replies were marked spoken, and nothing played until Dad tapped. The spoken-reply check read a clock that the stream's cleanup cleared, and a push notification woke a second cleanup just as the stream closed, so the two raced. The fix replaced the clock with a small gate. A spoken turn is expected when it is submitted. Whichever path sees its answer first (the stream draining, or a reload that retires the local record) claims it and reads it, exactly once. A reply whose moment has passed, because voice was turned off, another conversation is open, or Dad is already talking again, is dropped and never played late. A reply read at the wrong moment is worse than one not read at all. The gate removed the race, though not every silence: longer replies stayed quiet on the phone until the timeout told in Track 4 was found a couple of builds later.
A Recording Carries What Waits Beside It
Dad's next report: attaching a photo stopped his spoken turn from sending. Now the transcript joins whatever is already in the field, and the whole field goes: typed words, photos, and the recording's facts together.
Code
Three routes, a joining queue, and an exactly-once gate·typescript
// Where a finished recording goes, and who gets to play a spoken reply.
type Route = 'send' | 'queue' | 'composer';
export function routeTranscript(s: { voiceTurns: boolean; confirmBeforeSend: boolean; sendBusy: boolean }): Route {
if (!s.voiceTurns || s.confirmBeforeSend) return 'composer'; // Dad reviews first
return s.sendBusy ? 'queue' : 'send'; // never a fourth outcome
}
interface Queued { text: string; audioIds: string[] }
/** One waiting turn per conversation: a second recording joins the first,
* and a recording for another conversation waits beside it, never over it. */
export function enqueue(queue: Map<string, Queued>, conversationId: string, next: Queued): void {
const waiting = queue.get(conversationId);
queue.set(conversationId, waiting
? { text: `${waiting.text.trimEnd()} ${next.text.trim()}`, audioIds: [...waiting.audioIds, ...next.audioIds] }
: next);
}
/** A spoken reply is read exactly once, by whichever path sees it first,
* and never late: a reply whose moment has passed is dropped. */
export class SpokenReplyGate {
private expected = new Set<string>();
expect(requestId: string) { this.expected.add(requestId); }
claim(requestId: string, moment: { voiceOn: boolean; sameConversation: boolean; dadTalking: boolean }): string {
if (!this.expected.delete(requestId)) return 'skip: already read, or never expected';
if (!moment.voiceOn) return 'drop: voice was turned off';
if (!moment.sameConversation) return 'drop: another conversation is open';
if (moment.dadTalking) return 'drop: Dad is already talking';
return 'play';
}
}
console.log(routeTranscript({ voiceTurns: true, confirmBeforeSend: false, sendBusy: false }));
console.log(routeTranscript({ voiceTurns: true, confirmBeforeSend: false, sendBusy: true }));
console.log(routeTranscript({ voiceTurns: true, confirmBeforeSend: true, sendBusy: false }));
const queue = new Map<string, Queued>();
enqueue(queue, 'c1', { text: '내일 일정 알려주고', audioIds: ['u1'] });
enqueue(queue, 'c1', { text: '날씨도 봐줘', audioIds: ['u2'] });
enqueue(queue, 'c2', { text: '다른 얘기', audioIds: ['u3'] }); // kept, not overwritten
console.log(Object.fromEntries(queue));
const gate = new SpokenReplyGate();
gate.expect('turn-7');
const now = { voiceOn: true, sameConversation: true, dadTalking: false };
console.log(gate.claim('turn-7', now)); // the stream's drain sees it first
console.log(gate.claim('turn-7', now)); // the reload path sees it second
Run the code with npx tsx. Then write the missing piece: a flush function that, when a stream ends, sends the open conversation's queued turn if there is one and removes that entry, and a test that two recordings made during one streaming reply arrive as a single turn with both audio ids. Finally, add the case where the queued turn belongs to a different conversation than the one now open.
Hint
A queued turn for another conversation must not be sent into the open one. Either send it to its own conversation or return it to the composer there with a note; what it must never do is disappear. The queue is already keyed by conversation, so a second conversation's recording waits beside the first instead of replacing it; that is the same rule seen from the other side.
Progress
Progress is local-only — sign in to sync across devices.