リアルタイム文字起こしでは、同じ発話を含む区間が少しずつ重なって届くことがあります。たとえば、ストリーミング ASR が 0-900ms、780-1500ms、1460-2200ms の三つを返すと、表示側がそのまま連結しただけでは語句が重複します。
この問題の第一段階は「文字列をつなぐこと」ではなく、重なった時間区間を一つの候補範囲に正規化することです。本稿では、TypeScript で時間区間の和集合を実装し、不変条件と境界テストまで確認します。
先に結論
- 受信順には依存せず、開始時刻でソートしてから走査します。
- 現在の範囲と次の範囲が重なる、または端点で接するなら、終了時刻だけを伸ばします。
- この処理は区間の正規化です。最終テキストの採用には、別途 word timestamp や revision の規則が必要です。
- 計算量はソートを含めて O(n log n)、走査部分は O(n) です。
データモデルと不変条件
区間は半開区間 [startMs, endMs) として扱います。終了時刻を含めないため、[0, 500) と [500, 900) は時間的に隙間がありません。本稿では、音声が連続しているこのケースも一つの範囲にします。
統合後の配列に守らせる不変条件は次の二つです。
- 各要素で
startMs < endMsが成り立つ。 - 隣接する要素は重ならず、接してもいない。すなわち前の
endMs <次のstartMs。
二つ目を保てば、後段は「この時刻がどの発話候補に属するか」を二分探索や順次走査で扱えます。
実装
type Range = Readonly<{ startMs: number; endMs: number }>;
export function mergeRanges(input: readonly Range[]): Range[] {
const sorted = [...input]
.map((range) => {
if (!Number.isFinite(range.startMs) || !Number.isFinite(range.endMs)) {
throw new TypeError("startMs and endMs must be finite");
}
if (range.startMs >= range.endMs) {
throw new RangeError("startMs must be smaller than endMs");
}
return { ...range };
})
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const merged: Range[] = [];
for (const range of sorted) {
const current = merged.at(-1);
// current と離れているなら、新しい正規化済み範囲を始める
if (!current || current.endMs < range.startMs) {
merged.push(range);
continue;
}
// 重なり、または端点で接しているなら、終端だけを広げる
merged[merged.length - 1] = {
startMs: current.startMs,
endMs: Math.max(current.endMs, range.endMs),
};
}
return merged;
}
重要なのは比較演算子です。分岐を current.endMs < range.startMs にしているため、端点が同じ区間は統合側に入ります。話者の無音を別区間として残したい仕様なら、ここを「許容する無音時間」として明示的に設計します。たとえば 120ms まで結合したいなら、current.endMs + 120 < range.startMs のように書けます。
順不同で届いても正しい理由
入力を開始時刻でソートすると、走査中の current は「これまでに見た区間を統合した範囲のうち、最も右にあるもの」です。
次の区間が current.endMs より後に始まるなら、以後の区間もさらに後から始まるため、current と重なる可能性はありません。ここで確定して次の範囲を始められます。
逆に次の区間が current に接するか重なるなら、その和集合は開始時刻を変えず、終了時刻の大きい方を取るだけです。これを繰り返すことで、走査後にも上の不変条件が残ります。
Bun で境界を確認する
実装を載せるだけでは、端点の比較を取り違えたときに気付きにくくなります。Node.js の組み込み assertion を使えば、Bun でそのまま確認できます。
import { deepStrictEqual, throws } from "node:assert/strict";
deepStrictEqual(
mergeRanges([
{ startMs: 780, endMs: 1500 },
{ startMs: 0, endMs: 900 },
{ startMs: 1460, endMs: 2200 },
]),
[{ startMs: 0, endMs: 2200 }],
);
deepStrictEqual(
mergeRanges([
{ startMs: 0, endMs: 500 },
{ startMs: 500, endMs: 900 },
{ startMs: 1200, endMs: 1600 },
]),
[
{ startMs: 0, endMs: 900 },
{ startMs: 1200, endMs: 1600 },
],
);
throws(() => mergeRanges([{ startMs: 500, endMs: 500 }]), RangeError);
今回のコードは bun merge-overlap.ts で実行し、3 ケースが通ることを確認しています。
文字列の重複除去と混同しない
この関数が返すのは音声時間の候補範囲であり、文字列の正解そのものではありません。たとえば二つの ASR 結果が同じ 780-900ms を別表記で返したとき、時間だけではどちらの語を採用すべきか決められません。
実際のパイプラインでは、次の責務を分けると扱いやすくなります。
-
revision の選別: 同じ
segmentIdは新しい revision を採用する。 - 時間区間の正規化: 本稿の関数で重複候補をまとめる。
- 語単位の採用: word timestamp、信頼度、final フラグを使って文字列を決める。
- 表示差分の反映: 確定済み部分だけを UI にコミットする。
時刻の基準も重要です。STT プロバイダごとの相対時刻と、録音開始からの単調な時刻を混ぜると、正しいアルゴリズムでも重複判定が壊れます。統合の前に、すべての区間を一つの単調な audio clock に揃えてください。
まとめ
重なり合うストリーミング区間は、受信順で文字列を結合する前に時間区間として正規化します。開始時刻でソートし、重なりまたは接続を終了時刻の拡張に畳み込むだけで、後段の revision 選別とテキスト採用を分離できます。
面接で説明するなら、「これは文字列マージではなく、半開区間の和集合です。入力をソートして不変条件を保つので O(n log n) です」と最初に置くと、設計の境界が伝わります。