プロジェクト一覧

サウンドマインド

KSTT 韓国語スピーキング試験プラットフォーム

音声認識と合成を製品の中心に据えつつ、運用中に何が変わっても、すでに実施した試験の結果は揺らがないようにする仕事でした。

役割
受験 · 採点管理 · 音声パイプライン · デプロイの全領域
期間
2025.07 ~ 現在
使用技術
Next.js 15React 19TypeScriptPrismaMySQLSTTTTSffmpegDocker
アプリ / クライアントネイティブサーバーワーカーストレージ外部
Macでビルドした成果物が検証ゲートを通ってblue/greenスロットへデプロイされます。スキーマ変更は追加専用のみ通過します。ブロックにカーソルを合わせると、つながるフローだけが強調されます。ドラッグで移動できます。
  • 30余り

    採点と運用のための管理画面

  • 50

    データモデルの規模

  • 2種

    音声合成プロバイダを二系統にし、一方の障害時に代替

01課題

外国人学習者がブラウザで発音とスピーキングを録音して受験し、その録音を音声認識で文字起こしした後、採点者が採点するサービスです。最終目的は採点結果だけではなく、個人情報を削除し音量を均一に整えた学習用データセットを作ることでした。難しいのは、この全体が運用中も変わり続けるという点にありました。設問を修正し、実施回を追加し、デプロイを行う最中でも、すでに実施した試験の結果は絶対に変わってはいけません。

02制約

ゼロダウンタイムデプロイを使っているため、デプロイ中は旧コードと新コードがしばらく同時に動きます。このときデータベース構造が変わると旧コードが壊れます。音声認識は時間がかかるため別のプログラムが処理しますが、誤って2つが同時に動くと同じ録音を重複処理します。受験者側には、URLを操作したりブラウザの翻訳機能で問題を先に読んだりする迂回経路がありました。

  • デプロイ区間では旧バージョンと新バージョンが共存するため、スキーマ変更がそのまま障害になります
  • 文字起こしが重複すると、同じ録音を2回送ることになり、外部サービスの呼び出しコストがそのまま2倍になります
  • 試験URLと翻訳機能を通じた不正行為の経路が開いていました

03使い切りの道具から運用プラットフォームへ

最初に引き継いだ形は単純でした。試験を一度実施し、録音を採点すれば終わる使い切りの道具です。しかし実運用に入ると要求が変わりました。試験は学校と学期の単位で繰り返され、スタッフは管理者一人ではなく教師・採点者・検収者へと増え、録音は数百件ずつ積み上がります。道具をプラットフォームに育てる作業が、このプロジェクトの実際の本体でした。

道具がプラットフォームになった4段階

一度きりの試験ではなく、反復運用される試験サービスになりました

  1. 回次の導入

    試験を学校・学期単位の回次にまとめ、回次ごとに受験可能状態と遮断日、設問セットの割当を設けました。回次の状態はスケジューラが定期的に整合させ、設問とタイミングは受験時点のリビジョンに固定されるため、後の修正が過去の回次に及びません

  2. アカウント別権限システム

    学生とスタッフを入口で分け、スタッフ画面30余りはDBの権限キーでアクセスを判定します。役割別の権限に教師個人の例外を重ねられるため、新しい役割が生まれてもコード修正なしに権限の組み合わせで対応できます

  3. STTのバッチ化

    受験直後にその場で文字起こししていた構造を、キューベースのバッチへ移しました。常駐ワーカーがジョブを一件ずつロックして取得し、同時呼び出し数を制限するため、受験のピークが外部音声サービスへそのまま流れ込みません

  4. 採点画面の改善

    採点者が数百件の録音を処理する画面のため、流れがそのまま生産性です。録音を採点者へ自動分配し、波形ベースの再生で区間を確認し、発音の検収は別画面に分離しました。結果はExcelで書き出して学校へ渡します

04検討した代替案

文字起こしは1件に数十秒かかります。この処理をどこで動かすかが構造全体を決めました。

利点課題
リクエスト内での同期処理構造が最も単純で状態管理が不要アップロードリクエストが文字起こし完了までロックされ、タイムアウトとリトライが重なると同じ録音が重複して文字起こしされる
アプリサーバー内蔵スケジューラーデプロイ単位が1つに保たれるゼロダウンタイムデプロイ中はサーバーが2台になりスケジューラーも2つ動き、デプロイのたびに進行中の処理が途切れる
専用常駐ワーカー + 行ロックによる先取り採用ジョブを1件ずつアトミックに取得するため重複が根本から遮断され、デプロイと無関係に動き続けるワーカーが別プロセスのため、生死を別途管理する必要がある

05選択と根拠

専用ワーカーを選びました。残る問題はワーカー自身が落ちたり2つになったりするケースですが、これはワーカーに生存を定期的にDBへ記録させることで解決しました。他のワーカーの記録が生きていれば新しいワーカーは起動を拒否し、記録が止まれば正常終了なのか音信不通なのかを区別します。同じ基準をデプロイにも適用しました。元に戻せないものは、自動化経路からは触れないように塞ぐということです。

デプロイ前のスキーマゲート

デプロイ中は旧コードも動きます。削除構文はサーバーに届く前に止めます

  • デプロイ時のスキーマ変更は追加専用とし、削除構文が検知されるとサーバーに届く前にデプロイを中断
  • 録音は削除せず世代として積み上げ、管理者指示による再受験と自発的な再録音を区別
  • 設問は受験時点のリビジョンに固定し、修正が過去の実施回に影響しない
  • 受験画面の迂回経路を遮断: 割り当て外の設問へのアクセス、翻訳機能の悪用、マイクテストの有効性

06実装と試行錯誤

ビルドとデプロイの境界で想定外の問題が出ました。Macでビルドし、Linuxコンテナで実行する体制のため、ローカルでは問題なかったものがサーバーで壊れるというタイプでした。

  • ① 開発ツールパネルが本番バンドルに紛れ込んだことがあります。条件付きimportを整理したリファクタリングがバンドラーのコード除去を静かに壊したのが原因だったため、該当パターンをルールで禁止し、ビルド成果物を検査して確認するように変えました。
  • ② Macでビルドした成果物がコンテナで起動に失敗しました。DBエンジンと画像ライブラリがプラットフォーム別バイナリのため、Linux用バイナリを成果物に含めるようビルドを修正しました。
  • ③ 開発用ワーカーをローカルで起動したまま本番デプロイを行うと、文字起こしが重複しかけました。ワーカー識別子を設け、別の識別子の生存記録があれば本番ワーカーの起動を拒否するよう、デプロイスクリプトにガードを入れました。

07音声パイプライン

音声認識と合成のモデルは自分で作っていません。外部サービスを利用し、私の担当はそれを製品の中心経路に安全に据えることでした。受験者が録音を終えると外部の音声認識サービスが文字起こしを行い、設問の案内音声は外部の合成サービスで作ります。他者のサービスに依存する経路がそのままサービスの急所になるため、モデルそのものより、それが遅くなったり止まったりしたときに試験が崩れないようにすることに時間を使いました。

  • 文字起こし要求を別の常駐プログラムに分離し、同時呼び出し数を制限しました。外部サービスに一度に集中しないようにすると同時に、ジョブを1件ずつロックして取得し重複呼び出しを防ぎます
  • そのプログラムが生きているかを定期的に記録させて二重実行を防ぎ、正常終了と音信不通を区別します
  • 音声合成はプロバイダを二つ用意し、音声識別子で分岐しました。外部サービスはいつでも障害が起きたり方針が変わったりしうるため、一方が塞がってももう一方へ切り替えられるようにしたのです
  • 合成された音声は設問タイプ別の速度基準に合わせて後処理します
  • 学習データセットは音源1つを1行とし、個人情報を削除した後、匿名識別子でまとめてエクスポートします

08結果

デプロイがスキーマ事故で失敗することがなくなりました。破壊的なコマンドはデプロイスクリプトが自動的に弾くため、誤ってマイグレーションに削除構文を入れてもサーバーに到達しません。設問を運用中に修正しても、進行中またはすでに終わった実施回の結果は変わりません。音声認識と合成、データセットの3つのパイプラインは人が見守らなくても回る状態になり、録音が蓄積されると学習用データとして出ていく過程まで自動でつながります。