0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptでリアルタイム文字起こしの重複区間を統合する:時間区間の和集合と境界テスト

0
Posted at

重なり合う文字起こし区間を一つの時間範囲へ統合するタイムライン

リアルタイム文字起こしでは、同じ発話を含む区間が少しずつ重なって届くことがあります。たとえば、ストリーミング ASR が 0-900ms、780-1500ms、1460-2200ms の三つを返すと、表示側がそのまま連結しただけでは語句が重複します。

この問題の第一段階は「文字列をつなぐこと」ではなく、重なった時間区間を一つの候補範囲に正規化することです。本稿では、TypeScript で時間区間の和集合を実装し、不変条件と境界テストまで確認します。

先に結論

  • 受信順には依存せず、開始時刻でソートしてから走査します。
  • 現在の範囲と次の範囲が重なる、または端点で接するなら、終了時刻だけを伸ばします。
  • この処理は区間の正規化です。最終テキストの採用には、別途 word timestamp や revision の規則が必要です。
  • 計算量はソートを含めて O(n log n)、走査部分は O(n) です。

受信した区間を検証、ソート、統合する処理フロー

データモデルと不変条件

区間は半開区間 [startMs, endMs) として扱います。終了時刻を含めないため、[0, 500)[500, 900) は時間的に隙間がありません。本稿では、音声が連続しているこのケースも一つの範囲にします。

統合後の配列に守らせる不変条件は次の二つです。

  1. 各要素で startMs < endMs が成り立つ。
  2. 隣接する要素は重ならず、接してもいない。すなわち前の 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) です」と最初に置くと、設計の境界が伝わります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?