SESSION CHAT-072 / 48kHz / 25ms WINDOW / 10ms HOP

00:00 — 03:14.000

BOUNDARY LAB — SPEECH BOUNDARY DETECTION / BUILD v0.4.2

どこで切るかは、 ことば。 いつ切るかは、

動画の切り抜きが不自然に聞こえるとき、原因はたいてい一つしかない。境界が数百ミリ秒ずれている。BOUNDARY LAB は、切ってよい場所を文字起こしに決めさせ、正確に何ミリ秒で切るかを波形に決めさせる。役割を分けた途端、境界は人の耳が納得する位置へ落ちた。以下はスライドではなく、いま動いている実装そのものである。

帯の上にポインタを置くと、切断線が無音の谷へ吸着します。

SCROLL 00:00

CHANNEL 01 / MONO / 194.000s

TIMECODE 00:00

LAB / 02 RMS ENVELOPE VIEW

実験室 —
3分14秒の雑談を、
その場で切る

素材は編集前の雑談音声「CHAT-072」、収録は48kHz、長さ3分14秒。上のキャンバスは25ミリ秒窓・10ミリ秒ホップで計算したRMSエネルギー包絡で、縦軸はデシベル、横軸は時間そのものだ。下に並ぶ4本のレーンは、同じ時間軸に置いた4つの主張である。

VADは「音が途切れた場所」を、テキストは「文が終わった場所」を指す。両者はしばしば数百ミリ秒ずれ、ときには互いを完全に無視する。融合レーンだけが、意味の許可と物理の谷が重なった位置に印を打つ。再生し、方式を切り替えて、いちばん下の正解レーンとの距離を見てほしい。ずれは目で見える。

VAD
TEXT
FUSION
TRUTH
RATE ×4

00:00.000-57.8dB

  • 無音区間(VAD)
  • 文末トークン(テキスト)
  • 融合+無音スナップ後の境界
  • アノテータ中央値(正解)

N=120 / 3 ANNOTATORS / TOL ±120ms

TIMECODE 01:12

RESULTS / 03 JTALK-CHAT-24 / 120 CLIPS

3方式、
同じ音源での比較

評価は雑談コーパス JTALK-CHAT-24 から抽出した120クリップ。正解境界は3名のアノテータが独立に打った位置の中央値で、許容誤差±120ミリ秒以内を一致とみなした。VAD単独は取りこぼしこそ少ないが、息継ぎや相槌の隙間まで境界と主張して精度を落とす。テキスト単独は切り口の意味は正しいのに、位置の解像度が語単位までしかなく、語頭の子音を削る。融合は互いの弱点を打ち消し、平均境界誤差は240ミリ秒から32ミリ秒へ縮んだ。

01VAD ONLY

VAD単独

音だけを見る。取りこぼさないが、息継ぎまで境界と言い張る。

PRECISION0.61
RECALL0.88
F10.72

MEAN ERROR 240ms

ERROR DIST. ±120ms 内 = 白
02TEXT ONLY

テキスト単独

意味は正しい。ただし解像度が語単位までしかない。

PRECISION0.90
RECALL0.55
F10.68

MEAN ERROR 186ms

ERROR DIST. ±120ms 内 = 白
03FUSION

融合

意味が場所を許可し、音がミリ秒を確定する。両方が同意した位置だけが残る。

PRECISION0.93
RECALL0.86
F10.89

MEAN ERROR 32ms

ERROR DIST. ±120ms 内 = シアン

5 STEPS / ~40 LINES / RUNNING HERE

TIMECODE 02:40

PIPELINE / 04 SOURCE — VERBATIM

処理の内訳 —
5ステップ

実装そのものは難しくない。難しいのは、どの信号にどこまで決定権を与えるかという設計だ。以下の5つは、このページで実際に走っているコードである。

  1. 01RMS ENVELOPE
  2. 02SILENCE VAD
  3. 03SENTENCE SCORE
  4. 04SCORE FUSION
  5. 05SILENCE SNAP
01// 48kHz PCM を 10ms ホップの dB 包絡へ02function rmsEnvelope(pcm, sr) {03  const win = Math.round(sr * 0.025);04  const hop = Math.round(sr * 0.010);05  const n = ((pcm.length - win) / hop) | 0;06  const env = new Float32Array(n);07  for (let i = 0; i < n; i++) {08    let s = 0;09    for (let j = 0; j < win; j++) {10      const v = pcm[i * hop + j];11      s += v * v;12    }13    env[i] = 20 * Math.log10(Math.sqrt(s / win) + 1e-9);14  }15  return { env, floor: percentile(env, 0.10) };16}

RMS ENVELOPE

① RMSエネルギー抽出

48kHzのPCMを25ミリ秒窓・10ミリ秒ホップで走査し、フレームごとの二乗平均平方根をデシベルに変換する。下位10パーセンタイルを暗騒音とみなして基準化するので、収録環境が変わってもしきい値を引き直さなくてよい。ここで得た包絡が、以降すべての判断の土台になる。

01// 基準 -6dB を 80ms 以上。立上/立下で別しきい値02function detectSilence(env, floorDb) {03  const enter = floorDb - 6;04  const exit  = floorDb - 3;05  const out = [];06  let head = -1;07  for (let i = 0; i < env.length; i++) {08    if (head < 0 && env[i] < enter) head = i;09    else if (head >= 0 && env[i] > exit) {10      if ((i - head) * 10 >= 80) out.push([head, i]);11      head = -1;12    }13  }14  return out; // 意味は一切見ない15}

SILENCE VAD

② VAD無音検出

基準からマイナス6デシベルを下回るフレームが80ミリ秒以上続いた区間を無音とする。立ち下がりと立ち上がりに別のしきい値を置くヒステリシスで、息継ぎ由来のちらつきを抑える。この段階では意味を一切見ない。見る必要がない。

01// 文が閉じているか だけを 0–1 で採点する02const CLOSE = [/です$/, /ます$/, /でした$/, /。$/];03const OPEN  = [/けど$/, /ので$/, /って$/, /、$/];0405function sentenceScore(token) {06  const t = token.surface;07  if (OPEN.some((r) => r.test(t)))  return 0.08;08  if (CLOSE.some((r) => r.test(t))) return 0.95;09  return 0.42; // 判断材料なし10}11// 時間の話はしない。地図であって時計ではない。

SENTENCE SCORE

③ テキスト完結判定

文字起こしの各トークン末尾で、文末表現・句点・接続助詞の有無から「ここで文が閉じているか」を0から1で採点する。「〜ですね。」は高く、「〜だけど」は低い。時間の話は一切しない。テキストは意味の地図であって、時計ではないからだ。

01// 重み付き調和平均 — 片方が低ければ合成値も低い02function fuseScores(sil, sent, w = 0.42) {03  if (sil <= 0 || sent <= 0) return 0;04  return 1 / (w / sil + (1 - w) / sent);05}0607function candidates(sils, tokens) {08  return tokens09    .map((tk) => ({10      t: tk.end,11      s: fuseScores(silAt(sils, tk.end), sentenceScore(tk))12    }))13    .filter((c) => c.s > 0.55); // 両方が同意した位置だけ14}

SCORE FUSION

④ スコア融合

無音スコアと完結スコアを重み付き調和平均で合成する。調和平均を選んだ理由は単純で、どちらかが極端に低ければ合成値も低いから。つまり両方が同意した場所しか候補に残らない。

01// ±300ms 内で最もエネルギーの低いフレームへ02function snapToSilence(t, env, radiusMs = 300) {03  const r = radiusMs / 10;04  const c = Math.round(t * 100);05  const a = Math.max(0, c - r);06  const b = Math.min(env.length - 1, c + r);07  let best = c;08  for (let i = a; i <= b; i++) {09    if (env[i] < env[best]) best = i;10  }11  return best / 100; // 意味が場所を、音がミリ秒を12}

SILENCE SNAP

⑤ 無音スナップ

残った候補を、±300ミリ秒以内で最もエネルギーの低いフレームへ吸着させる。意味が場所を選び、音がミリ秒を決める。この最後の関数が、この研究の結論そのものだ。

↑ ページ上のポインタが行っているのは、この関数の可視化です。

240ms → 32ms / ~40 LINES

TIMECODE 03:02

CONCLUSION / 05

分業。
それだけのこと。

テキストは意味の地図を持っているが、時間の解像度を持たない。音は10ミリ秒の解像度を持っているが、意味を知らない。片方に全権を渡すから、切り抜きは不自然になる。境界の決定を「許可」と「確定」の二段に分け、前者をことばに、後者を音に任せる。実装にして約40行、平均誤差は240ミリ秒から32ミリ秒になった。研究の結論は、たいていこのくらい素っ気ない。

PERMISSION / 意味

DECISION / 音