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
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.
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
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
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
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.
| Option | Strengths | Drawbacks |
|---|---|---|
| Synchronous processing inside the request | Simplest structure, no state management needed | The upload request stays locked until transcription finishes, and when timeouts and retries overlap, the same recording gets transcribed twice |
| Scheduler embedded in the app server | Keeps the deployment as a single unit | During 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 claimingChosen | Jobs are claimed atomically one at a time, so duplication is blocked at the source, and it keeps running regardless of deploys | The 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.