All projects

Soundmind

KSTT Korean Speaking Test Platform

The work was to put speech recognition and synthesis at the center of the product, while making sure that no matter what changes in production, the results of exams already taken never waver.

Role
Full ownership: test taking · grading admin · speech pipeline · deployment
Period
2025.07 ~ Present
Stack
Next.js 15React 19TypeScriptPrismaMySQLSTTTTSffmpegDocker
App / clientNativeServerWorkerStorageExternal
Artifacts built on a Mac pass a verification gate into blue/green slots. Only additive schema changes get through.Hover a block to highlight its flows. Drag to pan.
  • 30+

    Admin screens for grading and operations

  • 50

    Data models

  • 2

    Speech synthesis providers, so one outage does not stop the exam

01The problem

Foreign language learners record their pronunciation and speaking in the browser to take the exam, the recordings are transcribed by speech recognition, and graders score them. The end goal was not just grading results but a training dataset with personal information removed and loudness normalized. The hard part was that all of this keeps changing while in production. While questions get edited, new exam rounds get added, and deploys go out, the results of exams already taken must never change.

02Constraints

We use zero-downtime deployment, so during a deploy the old code and the new code run side by side for a while. If the database schema changes at that moment, the old code breaks. Speech recognition takes a long time, so a separate program handles it, but if two of them accidentally run at once they process the same recording twice. On the test-taker side there were bypass routes: manipulating the URL, or reading questions in advance with the browser's translation feature.

  • Old and new versions coexist during a deploy, so a schema change becomes an outage
  • A duplicate transcription sends the same recording twice, which doubles the external service bill outright
  • Cheating paths through exam URLs and the translation feature were left open

03From a one-shot tool to an operated platform

What I inherited was simple: run one exam, grade the recordings, done. Real operation changed the requirements. Exams repeat by school and by term, staff grew from one admin into teachers, graders, and reviewers, and recordings pile up by the hundreds. Growing the tool into a platform was the actual body of this project.

four steps from tool to platform

Not one exam run once, but an exam service run repeatedly.

  1. Rounds

    Exams are grouped into rounds per school and term, each with its open state, blackout dates, and question-set assignment. A scheduler keeps round states consistent, and questions and timings are pinned to the revision in effect at exam time, so later edits never reach past rounds

  2. Per-account permissions

    Students and staff split at the entrance, and thirty-odd staff screens check permission keys stored in the database. Role-level grants can be overridden per teacher, so a new kind of staff member is a permission combination, not a code change

  3. Batch STT

    Transcribing right after each submission moved to a queue-based batch: an always-on worker claims jobs one at a time under a concurrency cap, so an exam-day peak never lands on the external speech service all at once

  4. Grading screens

    Graders work through hundreds of recordings, so flow is productivity. Recordings are distributed to graders automatically, waveform playback makes sections easy to check, pronunciation review lives on its own screen, and results export to Excel for the schools

04Alternatives considered

Transcription takes tens of seconds per item. Where to run this work determined the entire architecture.

OptionStrengthsDrawbacks
Synchronous processing inside the requestSimplest structure, no state management neededThe upload request stays locked until transcription finishes, and when timeouts and retries overlap, the same recording gets transcribed twice
Scheduler embedded in the app serverKeeps the deployment as a single unitDuring a zero-downtime deploy there are two servers, so two schedulers run, and in-flight jobs get cut off on every deploy
Dedicated always-on worker + row-lock claimingChosenJobs are claimed atomically one at a time, so duplication is blocked at the source, and it keeps running regardless of deploysThe worker is a separate process, so whether it is alive or dead has to be managed separately

05The decision and why

I chose the dedicated worker. The remaining problem was the worker itself dying, or two of them running, and I solved that by having the worker periodically record its liveness in the DB. If another worker's record is still fresh, a new worker refuses to start; when the record stops, we can tell a clean shutdown from a lost connection. I applied the same standard to deployment: anything irreversible must be kept out of reach of the automated path.

  • Schema changes at deploy time are additive only; if a destructive statement is detected, the deploy is aborted before it reaches the server
  • Recordings accumulate in generations with no deletion, distinguishing admin-ordered retakes from self re-recordings
  • Questions are pinned to their revision at test time, so edits never affect past exam rounds
  • Bypass routes on the test screen are blocked: access to unassigned questions, abuse of the translation feature, mic test validity

06Implementation and trial and error

Unexpected problems surfaced at the build and deploy boundary. Because we build on a Mac and run in a Linux container, things that worked fine locally would break on the server.

  • ① The dev tools panel once got mixed into the production bundle. A refactoring that cleaned up conditional imports had quietly broken the bundler's dead-code elimination, so I banned that pattern as a rule and changed the process to inspect the build output to confirm.
  • ② Artifacts built on a Mac failed to start in the container. The DB engine and image library ship platform-specific binaries, so I fixed the build to include the Linux binaries in the output.
  • ③ Leaving the dev worker running locally during a production deploy nearly caused duplicate transcriptions. I introduced worker identifiers and added a guard to the deploy script so the production worker refuses to start if a liveness record from a different identifier exists.

07The speech pipeline

I did not build the speech recognition or synthesis models. They are external services, and my part was seating them safely on the product's critical path. When a test taker finishes recording, an external speech recognition service transcribes it, and question prompts come from an external synthesis service. A path that depends on someone else's service is the weak point of the whole product, so I spent the time not on the models but on making sure the exam does not collapse when they slow down or stop.

  • Transcription requests run in a separate always-on worker with a capped number of concurrent calls, keeping the external service from being hit all at once, while row locks claim jobs one at a time to prevent duplicate calls
  • That program records its liveness periodically, which prevents double execution and distinguishes a clean shutdown from a lost connection
  • Speech synthesis runs through two providers branched by voice identifier. An external service can fail or change its terms at any time, so either one can carry the load alone
  • Synthesized audio is post-processed to meet the speed standard for each question type
  • The training dataset maps one audio file to one row, strips personal information, and exports it grouped under anonymous identifiers

08Results

Deploys no longer fail from schema accidents. Destructive commands are filtered out automatically by the deploy script, so even if a delete statement slips into a migration by mistake, it never reaches the server. Editing questions in production does not change the results of rounds in progress or already finished. The three pipelines, speech recognition, synthesis, and the dataset, now run without anyone watching, and once recordings accumulate, the flow all the way to exported training data continues automatically.