HomeWorkWhat I doContact
All projects

Foxy Junior case study

Scoring a child's voice without blocking them

Foxy Junior is an English adventure where speaking moves the story forward, so the app has to judge a young voice, survive a bad network and delete the recording as soon as it is scored.

Role
Flutter app and NestJS backend
Platforms
iOS, Android
Stack
Flutter, NestJS, Prisma, Azure Speech
Try it
Foxy Junior App Store preview: kids speak English, not just tap
Foxy Junior App Store preview: say a word and earn a sticker
Foxy Junior App Store preview: explore island by island

The problem

A game that listens

A speaking turn is a hard thing to put in front of a young child. Small fingers drift off a button, sentences trail away before the finger lifts, and a speech service can hear nothing, time out or score a long sentence poorly. None of those should feel like failing.

The audio is also the most sensitive data the product touches. A child's recording has to reach a scoring service and then disappear from the device, even if the app is killed mid-turn, and the text the recogniser heard has to stay out of analytics, crash reports and the database.

Architecture

The phone owns the speaking turn

The Flutter client runs the whole turn. It records a 16 kHz mono WAV, posts it directly to Azure Pronunciation Assessment and decides the outcome on the device. The NestJS backend never receives audio. Its speech job is to exchange the Azure key for a 10-minute token, behind an app key, a validated device id and rate limits.

I put the pass, retry and fallback ladder in one pure function so it can be tested without a widget, a microphone or a scorer. Scored turns go into an on-device queue and reach Postgres later, as numbers and reference words only, with a client event id so a retry cannot write a duplicate.

  1. Capture

    Flutter

    Hold-to-speak take, 16 kHz mono WAV

  2. Token broker

    NestJS

    Exchanges the Azure key for a 10-minute token

  3. Scoring

    Azure Speech

    Pronunciation scores for the sentence and each word

  4. Decision

    Dart

    Passed, retry, silent or fallback, decided on device

  5. Score log

    Prisma, Postgres

    Scores and reference words, no audio or transcript

The recording travels from the phone to Azure directly. The backend only sees the token request and, later, the numbers.

Key decisions

Three calls around a microphone

01

The button answers the finger

Opening a microphone takes several platform round trips, and a child can let go before it is running. Hold-to-speak then turned into tap to record, tap again to stop.

The screen flips to recording on the touch itself while the recorder catches up, and a flag remembers a release that came during opening, so the take stops the moment the mic is ready. The button reads raw pointer events, because a gesture recognizer cancels when a finger drifts. After release the mic stays open 400 ms longer, so the last syllable is still in the take.

  • A press under 500 ms earns a nudge to hold longer and costs no attempt.
  • Voice and music stop before recording starts.
  • The iOS audio session is warmed up on arrival at the step.

02

Only real attempts cost anything

A plain pass or fail collapses very different events: a mumble, a silent take, a timeout, a long sentence the recogniser scores low. Treating them alike would punish a child for a home network.

One pure function sorts each take into passed, retry, silent or fallback. A technical failure passes with the full reward and no attempt spent. When Azure hears nothing the child is asked again at no cost, and after 3 silent takes in a row, or the step's attempts, the child moves on with no star and half the XP.

  • The pass line is 60 out of 100, and a long sentence can pass on its best keyword score.
  • A whole scoring attempt is capped at 15 seconds, so the thinking state always ends.
  • Only speaking steps can trigger the fallback, so a touch game cannot cost a star.

03

A voice that cannot linger

The recording plugin writes to disk, so a child's audio exists as a file between the mic closing and the scorer answering. Azure also returns a transcript and lists extra words the child said, which can include their own name.

Each take gets a unique temp path, is deleted as soon as the scorer has read it, and is deleted again in a finally block on every way out. A sweep at startup catches what a process kill left behind. The transcript is stopped by allowlists on the device and again on the server, so a new field loses data instead of leaking it.

  • Extra words are dropped in the app, the DTO and the repository.
  • The speech log table has no audio or transcript column.
  • Crash reports run with screenshots and session replay off.

Let's build
something
together.

Open to senior roles: remote, hybrid or on-site in Ho Chi Minh City. The quickest way to reach me is email or LinkedIn.