One OpenAI Realtime voice component fills every form in a restaurant app, survives a loud room, and gets almost all of its tuning through the prompt.
The request from restaurant staff was not for an AI feature. It was for less tapping. Entering a reservation means name, phone, table note, guest count and duration, typed with one hand while standing, and that competes with the actual job. They wanted to say it once and have the form filled. Everything below is what that requirement turned into.
They wanted the microphone, and analytics agreed with them before I did. On screens where a form offered both a keyboard and a microphone, staff picked the microphone almost every time, consistently enough that extending voice to the remaining forms stopped being a decision.
Important part of the requirement: they did not want a conversation. Nobody wants to be asked "and how many guests?" by a phone while standing at a table. They want to say the whole reservation in one sentence and see it in the fields, then fix whatever is wrong and submit.
So the target was never a chat interface. It was structured form input where speech goes in and known field IDs come out.
OpenAI Realtime is a stateful, streaming API that keeps one session open over WebRTC or WebSocket and handles audio in and model output in the same connection. The alternative shape, and what I would have built two years ago, is record audio, upload it, transcribe with a speech API, then send the transcript to a second model for extraction. Three network round trips, two vendors, and every one of them adding latency the user feels.
Concretely what the API gives you here:
session.update sets output modalities, transcription model, and turn detection behaviour, and the session keeps that config until you change it.output_modalities to text means the model never synthesizes speech, so you are not paying for audio generation you would immediately discard.input_audio_transcription returns what the user said, separately from the structured response, which turns out to be useful for debugging and for fields where you trust raw transcription more than the model's interpretation.The fit is mostly about the pipeline collapsing. Speech arrives, extraction comes back, and there is only one place to configure and one place where things break.
The wrong build here is a voice implementation per screen. It works the first time, and then every new form brings its own microphone state, its own parsing, its own audio lifecycle bugs.
The shared component takes field definitions and a prompt builder. Per screen, only the extraction instructions and the output schema differ:
// the reservation screen. the gallery screen passes four text fields
// and gets the same sheet, same transport, same error handling
VoiceFormAssistantViewModel(
fields: [
VoiceField(id: "name", hint: "person name only, not a table note"),
VoiceField(id: "phone", hint: "digits only, strip spaces"),
VoiceField(id: "comment", hint: "seating or table preference"),
VoiceField(id: "peopleCount", hint: "integer"),
VoiceField(id: "duration", hint: "minutes as integer, convert if hours spoken"),
],
promptBuilder: ReservationVoicePrompt.build
)
Somebody says "Marko Petrovski, 070 123 456, by the window for 4 people for 2 hours" and the schema forces the shape of what comes back:
{
"name": "Marko Petrovski",
"phone": "070123456",
"comment": "by the window",
"peopleCount": "4",
"duration": "120"
}
Notice the duration. Person said hours, the form stores minutes, and that conversion lives in the instructions rather than in a parsing layer afterwards, because a parser would have to understand the sentence again from scratch.
Filled values go into the same local state the typed form uses. No separate submit path for voice, no second validation. Once the fields are populated the ordinary code sends the reservation, which is boring on purpose, since a voice feature with its own write path is a second place for bugs to live.
There is a useful side effect. If I cannot write the field contract for a screen, that screen is not ready for voice. Vague fields give vague extraction, and finding that out while writing a struct is much cheaper than finding it out after shipping.
Transport is standard: configure the audio session, create the peer connection, attach the microphone track, open a data channel, exchange SDP with the Realtime endpoint, then read events off the channel. I wrote that in an afternoon.
Two things after that were not standard.
Connection readiness. All of the above has to finish before captured audio can be trusted, and a user tapping a microphone starts speaking in roughly half second. Show a ready state too early and the server buffer begins mid-sentence, which reads exactly like the model misheard. There is no error and no low confidence to warn you. Fix is to open the connection when the sheet appears, cover the setup with a short countdown, and flip to ready only on the data channel open event.
An open session is not free. Realtime bills audio, and a live session with the microphone enabled keeps sending audio whether or not anybody is speaking. Leave a sheet open on a table for ten minutes and you are paying to transcribe a restaurant.
What I do about it:
Ambient conversation sits within about three metres of the microphone for the entire session, and that produced two failures worth naming.
First, warm-up audio. Keeping the audio track alive during connection setup improves reliability and captures the room before the user is meant to start. So immediately before signalling ready, send input_audio_buffer.clear. Without it every request opens with a second of somebody else's conversation, and leading audio has disproportionate effect on how the utterance gets segmented.
Second, turn detection. Server-side VAD commits when it detects speech offset, and it cannot tell whose speech ended. In a shared room a stranger's word gets committed into a customer field:
{
"type": "session.update",
"session": {
"type": "realtime",
"output_modalities": ["text"],
"audio": {
"input": {
"turn_detection": null,
"transcription": { "model": "whisper-1" }
}
}
}
}
With turn_detection null nothing commits until the user presses Stop. You lose the conversational feel, and for somebody dictating another person's phone number that is a good trade.
One more guard: enforce a minimum capture duration before committing, because users release Stop faster than the pipeline delivers and an empty commit reads as the assistant ignoring them. 2.5 seconds came from watching people, not from reasoning about buffers.
This is the part that surprised me. Almost everything that improved quality was prompt work, not audio work.
What helped, roughly in order:
What to be careful with: long instruction blocks degrade rather than improve, so adding a rule for every past mistake makes the next extraction worse. When I add a rule now I usually remove one. And the schema is the real guard, not the prose. A closed enum and a required field catch drift that instructions alone will not.
Transcription quality across Macedonian, Serbian, Bulgarian and English was better than I expected, and this was the risk I had priced highest before starting. Three of those are South Slavic and two share Cyrillic, and I assumed I would end up with per-language models or at least per-language prompts.
I did not. The same session config handles all four, and staff switching between them mid-sentence, which happens constantly here, mostly works.
That is the reason the concept of this build ended up being prompt tuning rather than audio engineering. When recognition is not the bottleneck, quality lives in how well you described the form to the model, and that is a text file you can iterate in minutes.
Why use OpenAI Realtime instead of speech-to-text plus a second model call?Realtime keeps one session for capture, transcription and extraction, so you avoid uploading audio, waiting for a transcript, then making a separate extraction request. Fewer round trips, one place to configure, and one place where things break.
How do you get structured fields out of OpenAI Realtime instead of plain text?Declare the field IDs and a format rule for each in the session instructions, request the response only when the user stops, and validate against a schema before applying. The schema does more work than the prose, since it catches invented field names and wrong types that instructions alone will not.
Does OpenAI Realtime handle languages other than English well?In my testing across Macedonian, Serbian, Bulgarian and English, yes, with the same session config and no per-language prompts. Staff also switch languages mid-sentence and that mostly survives, which was the outcome I had least expected.
How do you keep OpenAI Realtime costs down in a mobile app?Do not hold sessions open. Open on sheet presentation, disable the local audio track between capture windows, close the connection on dismiss, background and error, and add an idle timeout. An open session with a live microphone bills audio whether anybody is speaking or not.
Voice input on these forms works, and it works mostly because very little of it is clever. One shared component, per-screen instructions and a schema, connection warmed before capture, turn detection off, buffer cleared at the boundary. The model was rarely the thing that was wrong.
If you build something similar, order matters. Get the audio lifecycle correct first, then spend your time writing instructions, because that is where the remaining quality lives. Four languages on one session config was the part I expected to fight with and did not.
What I do not have yet is a number. The only honest measure of quality here is how often staff correct a field before pressing submit, and until I collect that, everything above is judgement rather than measurement.