HomeWorkWhat I doContact
All projects

NihaoMando case study

Grading spoken Mandarin without guessing

NihaoMando listens to a learner read Chinese aloud and scores each character, and the scoring is built to say nothing when it cannot be sure.

Role
Flutter app and speech scoring
Platforms
iOS, Android
Stack
Flutter, Speech scoring, Supabase
Try it
NihaoMando App Store preview: an HSK path for working adults
NihaoMando App Store preview: talk live with your AI tutor
NihaoMando App Store preview: pronunciation scored character by character

The problem

Why speech is hard to grade

Mandarin learners need to know which character went wrong and whether the tone was right. The pronunciation vendor scores whole words, and none of the fields we map from its response describes tone. A score that looks precise can still be wrong about the thing the learner came to practise.

A wrong verdict costs more than a missing one. Tell learners they got a tone wrong when they got it right, and they stop believing every score after it. The product also runs on a phone, where people double-tap, take calls mid-recording and lose connection.

Architecture

Two scorers behind one response

This was a team project. I worked on the Flutter app and co-wrote the speech-scoring service. The app records 16 kHz mono WAV and posts it to a NestJS endpoint, where Azure pronunciation assessment grades the sounds and a pitch tracker written in TypeScript grades the tones from the same audio.

Azure sits behind a small provider interface, so its field names never reach the service layer or the app. The tone verdict is an optional addition to the response: if the pitch code throws, the learner still gets the Azure score.

  1. Capture

    Flutter

    Hold to speak, 16 kHz mono WAV

  2. API

    NestJS

    Quota check, then one scoring path per take

  3. Sound scoring

    Azure Speech

    Word scores mapped back onto each character

  4. Tone scoring

    YIN pitch tracker

    Contours matched to tone templates, or declined

  5. Feedback

    Flutter

    Per-character results, tone shown only when earned

The tone pass runs after the Azure call and is wrapped so a failure there can only remove the tone verdict.

Key decisions

Where the hard calls were

01

A tone scorer allowed to decline

Azure's assessment gives accuracy, fluency and completeness, but no tone result. A tone check that guesses would accuse learners of errors they did not make.

We wrote a pitch pipeline that tracks F0 with YIN, normalizes it to semitones around the speaker's own median and fits each syllable against tone-shape templates. Every stage can return undetermined instead of a verdict. Tone 3 is switched off in both directions, a deliberate cost: the scorer stays quiet on that tone rather than guess.

  • Verdicts are limited to tones 1, 2 and 4.
  • The best template must beat the runner-up by a 0.4 margin.
  • Pass or fail never depends on tone.

02

Not trusting a Success response

With miscue detection on, a Success response with every word omitted can mean silence or a learner reading the wrong sentence. Handling either the wrong way stores a zero nobody earned or discards a real attempt.

The Azure adapter reports only the fact that nothing matched, and the service decides using the pitch pass already run on the same audio. No voiced audio means silence and a retry. Voiced audio keeps the real zero. If the pitch pass cannot read the file, the score stands, because throwing away a real take is the worse mistake.

  • A Success with no assessment scores becomes a provider error, never a zero.
  • Azure's words are aligned to characters by a forward search that steps over punctuation.
  • Silent takes are not saved to the learner's history.

03

The latest take always wins

Recording, closing the file, uploading and scoring all overlap on a phone. A quick retry could score an old file or let a late response overwrite a newer take.

Each take gets a generation number, and every async continuation checks it before touching state, so a late response is dropped. A cancel also aborts the HTTP request. The screen shows the scoring state the moment the finger lifts, and the result is saved once.

  • A new take waits for the previous recorder stop before opening the mic.
  • The app blocks recording up front when scoring quota is spent.
  • The audio upload runs in the background and never holds up the score.

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.