ネタを拾って短い動画を作る自作ツールの納品音声を再生したら、消したはずの破裂音が耳に残った。

波形を拡大すると、ピーク値0.015の小さなトゲが静かな区間の真ん中に取り残されている。

前回の処理でクリック音の前半だけを削り、減衰途中の残骸がそのまま外へ出ていた。

一度は検品を通って無音と判定された場所から、ノイズが再び鳴り響く。

無音区間のクリック音残り

振幅の静音線によって検出対象から除外された減衰波形の残骸を示す図
最大振幅の2%で引かれた静音線の内側に、ピーク0.015のクリック音残骸が取り残される。

自作ツールの音声処理では、librosa.effects.splitの実装と同じように、全体の最大音に対する相対的な音量で無音と有音の境界線を引いていた。ファイル内の最大RMS値から計算した静音線は最大振幅の約2%に位置する。

ピーク0.015という減衰残りは、この静音線より完全に下だった。

計算量を節約する意図で、静かな区間と判定された帯域全体を探索対象から外していた。

外側からノイズのエッジを追っていく拡張ループも組んでいた。

しかし、減衰途中のクリック音内部に振幅が極端に落ちる瞬間が一度でも挟まると、探索ループはそこで停止する。

それより後ろに残った波形は、静かな連続区間の一部として丸ごと放置された。

耳には明瞭に聞こえるノイズが、アルゴリズムの目には無音として映る。

BGM帯域と整流波形の測定

低域に偏るBGMスペクトルと高域に偏るクリック音スペクトルの比較図
BGMのエネルギーは大半が低域に偏り、高域に突き刺さるクリック音をマスキングできない。

背後に音楽が鳴っていれば小さなノイズは覆い隠されるという想定を置いていた。

だが、実際のBGMトラックの周波数を測ると、全体のエネルギーの86%から95%が0〜1kHzの低周波帯に偏っている。

対するクリック音のエネルギーは、58.6%が3〜9kHzの高周波帯に集中していた。

音響マスキングの研究が示すように、ある音が別の音を遮蔽する効果は同一の臨界帯域やその隣接帯域を中心に働く。

低域に偏ったBGMは、高域に突き刺さるトゲを何ひとつ覆い隠さない。

さらに首をかしげたのは、スペクトルの測定ルーチンが出す数値だった。

scipy.signal.spectrogramへ渡す前処理で、旧コードは波形を絶対値で整流していた。

同じ音声ブロックを再測定するたび、高域成分比として58.6%と20.4%という互いに矛盾する数値が返ってくる。

判定の土台そのものが揺れていた。

符号付き波形と実測しきい値

符号付き波形に基づくパワースペクトル密度と35%しきい値の判定基準を示す図
整流波形を捨てて符号付き波形で測定し、高域比率35%の実測しきい値で判定する。

整流処理を捨てて素の符号付き波形による測定へ切り替えると、ブロックごとの高域成分比は一意に定まった。scipy.signal.periodogramでパワースペクトル密度を推定し、静区間内部も均等に走査する検出器へ差し替える。

問題は、どこにしきい値を引くかだ。

理論値から逆算するのをやめ、今回の素材で鳴っているクリック音の実測値から直接境界を引いた。

「3〜9kHzの成分が35%以上を占め、かつ低域成分より大きいブロック」をクリック音とみなして切り落とす。

35%という数字は、対象素材のスペクトルを直接確かめて割り出した境界線だ。

別の音声や異なる種類のノイズへそのまま流用できる保証はない。

それでも、目の前で生き残っている過渡ノイズを確実に捕捉するには、この実測値が必要だった。

静区間走査と誤検出保護

静区間走査と語頭低域保護および15ミリ秒境界保護の動作を示す図
静区間内部を全走査しつつ、低域優勢な語頭と区間端15msを保護して誤除去を防ぐ。

静かな区間の内部まで網を広げると、本来残すべき声の成分を巻き込む危険が跳ね上がる。

言葉の出だしにある柔らかい立ち上がり成分は、実測すると59.7%から74.5%が低域に集まっている。

低域が優勢なブロックをクリック判定から除外する保護条件を入れた。

静音区間の端から15ミリ秒以内にあるブロックも、境界処理の乱れを防ぐため強制的に保護対象として残す。

修正後に11シーンすべての音声に対して再スキャンを走らせた。

4箇所のノイズブロックが除去され、再スキャンでの検出数は0になった。

納品音声の該当区間を1ミリ秒刻みの解像度で確認し、完全な無音が保たれている裏付けを取る。

既存のテストケース870件もすべて通った。

BGMが綺麗に隠してくれると思い込んでいた高域ノイズを、低音ド直球のBGMで覆えるわけがない。

安全だと決めつけて探索を外した静音区間で、ピーク0.015の破裂音を一番大事に保護している。