VAD単独
音だけを見る。取りこぼさないが、息継ぎまで境界と言い張る。
MEAN ERROR 240ms
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
素材は編集前の雑談音声「CHAT-072」、収録は48kHz、長さ3分14秒。上のキャンバスは25ミリ秒窓・10ミリ秒ホップで計算したRMSエネルギー包絡で、縦軸はデシベル、横軸は時間そのものだ。下に並ぶ4本のレーンは、同じ時間軸に置いた4つの主張である。
VADは「音が途切れた場所」を、テキストは「文が終わった場所」を指す。両者はしばしば数百ミリ秒ずれ、ときには互いを完全に無視する。融合レーンだけが、意味の許可と物理の谷が重なった位置に印を打つ。再生し、方式を切り替えて、いちばん下の正解レーンとの距離を見てほしい。ずれは目で見える。
00:00.000-57.8dB
N=120 / 3 ANNOTATORS / TOL ±120ms
TIMECODE 01:12
評価は雑談コーパス JTALK-CHAT-24 から抽出した120クリップ。正解境界は3名のアノテータが独立に打った位置の中央値で、許容誤差±120ミリ秒以内を一致とみなした。VAD単独は取りこぼしこそ少ないが、息継ぎや相槌の隙間まで境界と主張して精度を落とす。テキスト単独は切り口の意味は正しいのに、位置の解像度が語単位までしかなく、語頭の子音を削る。融合は互いの弱点を打ち消し、平均境界誤差は240ミリ秒から32ミリ秒へ縮んだ。
音だけを見る。取りこぼさないが、息継ぎまで境界と言い張る。
MEAN ERROR 240ms
意味は正しい。ただし解像度が語単位までしかない。
MEAN ERROR 186ms
意味が場所を許可し、音がミリ秒を確定する。両方が同意した位置だけが残る。
MEAN ERROR 32ms
5 STEPS / ~40 LINES / RUNNING HERE
TIMECODE 02:40
実装そのものは難しくない。難しいのは、どの信号にどこまで決定権を与えるかという設計だ。以下の5つは、このページで実際に走っているコードである。
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
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
基準からマイナス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 / 音