音声文字起こしサービスを作り始めた頃は、認識結果を画面に表示できれば、ひとまずプロダクトとして成立すると思っていました。
実際に会議やインタビューのファイルを使ってみると、難しかったのは文字を生成する部分より、その後の編集でした。
文字起こし結果には、次のような修正が必要になります。
- 人名や製品名の修正
- 話者ラベルの変更
- 句読点や改行の調整
- 元音声との照合
- 不要な発言の削除
- 字幕用タイミングの確認
この記事では、音声・動画文字起こしWebアプリを作る際に考えた、編集画面のデータ構造とUI設計についてまとめます。
文字起こし結果を単なる文字列として扱わない
最初に考えたデータ形式は、非常に単純なものでした。
type Transcript = {
text: string;
};
しかし、この形式ではタイムスタンプ、話者、音声再生位置を扱えません。
実際の編集画面では、文字起こし結果をセグメントの配列として管理した方が扱いやすくなります。
type TranscriptSegment = {
id: string;
startTime: number;
endTime: number;
speakerId: string | null;
text: string;
};
type Transcript = {
duration: number;
segments: TranscriptSegment[];
};
この構造にしておけば、各文章と音声位置を結び付けられます。
たとえばユーザーが文章をクリックした際に、startTimeの位置から音声を再生できます。字幕を書き出す場合も、同じ開始時間と終了時間を利用できます。
一つのデータ構造を、編集、再生、検索、字幕出力で共通利用できるのが重要でした。
テキストとメディアプレーヤーを同期する
編集画面では、次の2方向の同期が必要です。
- 文章をクリックすると、対応する位置から再生する
- 再生位置が変わると、現在の文章をハイライトする
文章から音声への移動は簡単です。
function playSegment(
media: HTMLMediaElement,
segment: TranscriptSegment
) {
media.currentTime = segment.startTime;
void media.play();
}
反対に、現在時刻からセグメントを探す処理は頻繁に実行されます。
function findActiveSegment(
segments: TranscriptSegment[],
currentTime: number
): TranscriptSegment | undefined {
return segments.find(
segment =>
currentTime >= segment.startTime &&
currentTime < segment.endTime
);
}
短い録音なら線形検索でも問題ありません。
ただし、数時間のファイルでセグメント数が多くなると、timeupdateのたびに全件検索するのは効率的ではありません。実装時には、直前のインデックスを保持する方法や二分探索を検討できます。
function findSegmentIndex(
segments: TranscriptSegment[],
currentTime: number
): number {
let left = 0;
let right = segments.length - 1;
while (left <= right) {
const middle = Math.floor((left + right) / 2);
const segment = segments[middle];
if (currentTime < segment.startTime) {
right = middle - 1;
} else if (currentTime >= segment.endTime) {
left = middle + 1;
} else {
return middle;
}
}
return -1;
}
ここで注意したいのは、認識結果のセグメント間に小さな空白が存在する場合です。
endTimeと次のstartTimeが完全には連続しないため、再生中にアクティブな文章が一瞬消えることがあります。前後に許容値を設けるか、最も近いセグメントを選ぶ必要があります。
編集単位を細かくしすぎない
一単語ごとに時刻情報を持たせれば、カラオケ字幕のような表示もできます。
しかし、通常の文字起こし編集では粒度が細かすぎます。
単語単位のDOMを大量に作ると、次の問題が起きやすくなります。
- 長時間ファイルで表示が重くなる
- テキスト選択が不自然になる
- 複数単語の修正が面倒になる
- 改行や段落の管理が複雑になる
反対に、数分間の発言を一つのテキストにまとめると、音声位置との対応が分かりにくくなります。
そのため、編集画面では「一つの意味のまとまり」または「数秒から数十秒程度の発言」を一つのセグメントとして扱うのが現実的でした。
単語レベルの時刻データが必要な場合も、表示用セグメントとは別に保持した方がUIを単純にできます。
話者名はセグメントごとに保存しない
次のように話者名を直接保存すると、後から名前を変更する際に、対象となる全セグメントを書き換える必要があります。
type TranscriptSegment = {
speakerName: string;
text: string;
};
代わりに、話者を別のデータとして管理します。
type Speaker = {
id: string;
name: string;
};
type TranscriptSegment = {
id: string;
speakerId: string | null;
text: string;
};
const speakers: Speaker[] = [
{ id: "speaker-1", name: "インタビュアー" },
{ id: "speaker-2", name: "ゲスト" }
];
これなら、Speaker 1を実際の名前へ変更しても、話者データを一度更新するだけで済みます。
話者認識の結果は常に正しいとは限りません。似た声、同時発話、途中参加、録音環境の変化などがあるため、話者名だけでなく、セグメントの話者自体も変更できる必要があります。
保存済みデータと編集中データを分ける
テキスト入力のたびにサーバーへ保存すると、リクエストが多くなります。
一方で、保存ボタンだけに頼ると、ブラウザを閉じた際に編集内容が失われる可能性があります。
実装方法としては、次のようなデバウンス付き自動保存が考えられます。
function debounce<T extends (...args: any[]) => void>(
fn: T,
delay: number
) {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const saveTranscript = debounce(async (segments) => {
await fetch("/api/transcripts/current", {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ segments })
});
}, 1000);
画面上では、保存状態をユーザーに伝える必要があります。
- 編集中
- 保存しています
- 保存済み
- 保存に失敗しました
保存失敗を表示せず、ユーザーがページを離れてしまうのが最も避けたいケースです。
エクスポートは保存済みデータから生成する
文字起こし編集画面では、TXT、DOCX、SRT、VTTなど複数の形式が必要になります。
ここで起きやすい問題が、画面に表示されている内容とダウンロードファイルの内容が一致しないことです。
たとえば自動保存の完了前にエクスポートを開始すると、一つ前のバージョンが出力される可能性があります。
安全な流れは次の通りです。
- 編集内容を保存する
- 保存が成功したことを確認する
- サーバー上の最新データから出力を生成する
- ダウンロードを開始する
async function exportTranscript(format: string) {
await flushPendingChanges();
const response = await fetch(
`/api/transcripts/current/export?format=${format}`
);
if (!response.ok) {
throw new Error("Export failed");
}
const blob = await response.blob();
downloadBlob(blob, `transcript.${format}`);
}
SRTやVTTの場合、テキストだけでなく時間情報の整合性も確認する必要があります。
開始時刻が終了時刻より後になっていないか、前後の字幕が不自然に重なっていないかを出力前に検証します。
音声と動画で編集画面を分けない
音声と動画では入力ファイルが異なりますが、文字起こし後の操作はほぼ同じです。
- 再生する
- テキストを確認する
- 話者を修正する
- 検索する
- エクスポートする
そのため、音声用と動画用に別々の編集画面を作るより、HTMLMediaElementを共通インターフェースとして扱う方が保守しやすくなります。
実際の操作フローを確認できるよう、音声向けにはMP3 to Transcript、動画向けにはMP4 to Transcriptという入口を用意していますが、処理後は同じ考え方の編集ワークスペースを使用しています。
入力形式ごとにプロダクトを分けるのではなく、入力後の作業を共通化する方が、機能追加もしやすくなりました。
長時間処理では進捗より「状態」が重要
文字起こしは、ファイルの長さや混雑状況によって待ち時間が変わります。
正確な進捗率を計算できない状態で、根拠のないパーセントを表示すると、99%で長時間止まるような不自然な体験になります。
そのため、次のように処理状態を明確に分ける方が分かりやすくなります。
type JobStatus =
| "uploaded"
| "preparing"
| "transcribing"
| "post_processing"
| "completed"
| "failed";
各状態に応じて、ユーザーへ説明を表示します。
const statusMessages: Record<JobStatus, string> = {
uploaded: "ファイルを受け付けました",
preparing: "音声を準備しています",
transcribing: "文字起こしを実行しています",
post_processing: "結果を整えています",
completed: "文字起こしが完了しました",
failed: "処理を完了できませんでした"
};
失敗時には単に「エラー」と表示するのではなく、再試行できるのか、別形式へ変換すべきか、サポートへ連絡すべきかを案内する必要があります。
まとめ
文字起こしサービスでは認識モデルや精度が注目されがちです。
しかし、実際にユーザーが長く触るのは処理中の画面ではなく、処理後の編集画面です。
特に重要だったのは次の点でした。
- 文字列ではなく時間付きセグメントとして管理する
- テキストとメディア再生を双方向に同期する
- 話者を独立したデータとして管理する
- 編集内容を安全に自動保存する
- エクスポート前に最新状態を確定する
- 音声と動画で編集ロジックを共通化する
- 不確かな進捗率より処理状態を明確に表示する
認識精度を高めても、修正しにくい画面では実用的な文字起こしサービスになりません。
多少の認識ミスがあっても、元音声をすぐ確認でき、迷わず修正できること。その編集体験まで含めて設計することが、文字起こしWebアプリでは重要だと感じました。
