Tags voice inputkorean parsingon-device sttregexproduct
All posts

voice input

I added voice input for todos because planning was tedious

Typing todos into my own app annoyed me, so I built voice input for one spoken sentence. No model, just regex, and a split rule that nearly cut verbs in half.

August 15, 2026 5 min

Adding a todo to my own app annoyed me.

Tap the button, type the title, pick a time, save. Four taps per item, twelve for three items, when the sentence in my head was already complete: “bank call tomorrow at two, run in the evening, twenty pages of the book.”

So I made the app take that sentence.

I had gone the other direction days earlier on the same screen, when I turned off a sheet that opened automatically to prompt me to plan. That change removed the nagging. This one removed the cost.

Voice input without calling a model

Voice input implies parsing “bank call tomorrow at 2pm”, which sounds like a job for a language model. That was my first instinct too.

But the patterns people actually use when speaking a todo are few. The date is today, tomorrow, a day number, or a weekday. The time is a morning or afternoon marker plus an hour and maybe minutes. A name is attached somewhere. That is close to the whole grammar.

Calling a model for something a regex finishes adds tokens, a network round trip, and a hard dependency on connectivity. It would not work on a plane. So I wrote the parser, and decided that whatever it cannot handle can be escalated to a model later.

Recognition itself runs on device. Speech never leaves the phone, which also removes an entire privacy conversation from the feature.

Splitting Korean on the wrong token cuts verbs in half

One sentence can hold several todos, so something has to split it. Commas and the word for “and” were obvious. I nearly added the connective particle ~하고 as well, and stopped.

Consider 청소하고 빨래, meaning “clean and do laundry”. Here 하고 is not a connective at all. It is part of the verb 청소하다. Splitting on it invents a todo called 청소 (a noun fragment that is not the verb) and strands 빨래 without context.

Korean does not put spaces around its connectives the way English does, so a separator that is a substring of ordinary words is a genuine hazard rather than a theoretical one.

I narrowed the separator set to newlines, commas, the standalone words for “and”, “then”, “next”, and a whitespace-delimited “also”. Here is the parser run while writing this post:

input: 청소하고 빨래
  1 part: ["청소하고 빨래"]

input: 내일 오후 2시에 은행 전화, 저녁 7시 러닝 그리고 책 20쪽
  3 parts: ["내일 오후 2시에 은행 전화","저녁 7시 러닝","책 20쪽"]
  → "은행 전화"  2026-08-16 14:00  (explicit)
  → "러닝"       2026-08-15 19:00  (none)
  → "책 20쪽"    no time           (none)

The rule is not free

The same run also produced this:

input: 운동하고 회의
  1 part: ["운동하고 회의"]

In this phrase 하고 really is a connective, so the user said two things and got one todo.

The rule protects 청소하고 at the cost of 운동하고 회의, and I chose that direction deliberately. A valid todo split into a meaningless fragment is worse than two todos arriving on one line, because the first failure is hard to diagnose and the second is visible the moment you look at the list.

I would rather be wrong in the direction the user can see and fix in one tap.

When it cannot parse a time, it does not ask

I also had to decide what happens when no time is found.

Asking a follow-up question is more accurate, and it also destroys the only advantage voice has, which is speed. So the parser keeps the name, drops the time, and creates the todo anyway. That is what happened to “20 pages of the book” in the run above.

This only works because “no time set” is already a first class state in the app, with its own place in the UI. If there had been nowhere to put a timeless todo, I could not have made this call, and the feature would have needed a dialog instead.

Dates get one more rule. A past day number or weekday rolls forward to the nearest future occurrence, which is why “the 14th” became September 14th when I ran this on August 15th. But an explicit “today” never rolls. Someone saying “today at 2” at 3pm knows the time has passed and is recording it on purpose.

A loop gave every item the same sort order

The real bug was in saving multiple items.

My first version called the existing single-item add function in a loop. Every one of the N items came back with an identical sort order, and the list rendered in an arbitrary sequence.

The cause is that the function derives the order from the maximum in the current list, and that list is refreshed by a server listener. The loop does not wait for the listener, so the second and third iterations are still reading the pre-update list and computing the same number from it.

The fix is to read the list once, add the index, and write everything in a single batch:

const baseOrder = oneOff.reduce((m, t) => Math.max(m, t.order ?? 0), 0);
const batch = writeBatch(todosRef.firestore);
items.forEach((it, i) => {
  batch.set(doc(todosRef), {...todo, order: baseOrder + 1 + i});
});
await batch.commit();

Notifications are scheduled after the commit resolves, not before. If the write fails and the notifications are already queued, the phone rings for a todo that does not exist.

The generalisation I took away: a function that derives a value from asynchronously refreshed state cannot be called in a loop. If you need to create N of something, write a function that creates N of them.

What it actually bought

Now I press the mic and say one sentence. “Bank call tomorrow at two, run at seven, twenty pages of the book” becomes three rows, and what used to be twelve taps is two.

It is a small feature. One button on one screen. But it is the part of the app I use most, because it did not try to make me plan more. It made writing the plan down cheap enough that I stopped avoiding it.

Scope

Verified on 2026-08-15 on an Expo SDK 56 app. The parser output above was produced by running the module while writing this post, not from memory.

I have not measured recognition accuracy. How the on-device engine handles accents or noisy rooms is anecdote from my own handset, not a number I can defend. The escalation path that hands unparsed sentences to a model does not exist yet either.

Previous The JS ran at 60fps and the screen stayed black