動画の無音部分を自動でカットする

動画内に一定時間無音が続くシーンがあったら自動でカットするプログラムをPython(Google Colab)でプロトタイピングしてみることにした。

方針

下記のステップで、無音シーンの自動カットを試してみることにした。

  1. 動画ファイルの音声トラックを抜き出す
  2. 音声トラックを分析して、「カットしても良さそうな箇所」を探す
  3. 元の動画ファイルから、「カットしても良さそうな箇所」以外の箇所を抜き出してつなげる

なお、検証にはじんぼくんがTwitter / TikTokにアップロードしているこちらの動画の編集前の素材を使わせてもらった。

動画から音声を抜き出す

まずは、処理したい動画から音声トラックのみを抜き出してファイルに書き出す。

ffmpeg -i navi_a.mov -ac 1 -ar 44100 -acodec pcm_s16le navi_a.wav

ファイルに書き出さず、動画ファイルから直接音声トラックのデータを読み込んでも良いが、WAVファイルにしておくことでPySoundFileなどのライブラリを使って簡単に読み込める、ステレオをモノラルに変換する処理が省ける、波形データを音声編集ソフト(Audacityなど)で確認できる、などのメリットがある。

波形データを読み込む

PySoundFileを使って音声ファイルを読み込み、matplotlibを使って波形を表示する。

import soundfile as sf
import os
import numpy as np
from matplotlib import pyplot as plt

src_file = os.path.join("/gdrive", "My Drive", "audio-cut-exp", "navi_a.wav")

data, samplerate = sf.read(src_file)
t = np.arange(0, len(data))/samplerate
plt.figure(figsize=(18, 6))
plt.plot(t, data)
plt.show()

音声ファイルは16bitのWAVだが、PySoundFileを使って読み込むと振幅が-1.0〜1.0の間に収まるFloat型で波形データが読み込まれる。今回の音声は最大の振幅が0.3程度、無音部分(喋っていない部分)でも0.02ぐらいのノイズがのっているようだ。今回は省略してしまったが処理をする前にノーマライズをしておいた方が良いかもしれない。

一定のレベル(振幅)以上のサンプルにフラグを立てる

この波形から、「カットしても良い部分」を探していく。まずは、波形データのうちレベル(振幅)が一定の値を超える箇所と、下回る箇所をマークしていく。上の波形画像をもとに、閾値を0.05としてみた。

thres = 0.05
amp = np.abs(data)
b = amp > thres

なおこのグラフで、青い箇所は「閾値の上下を行ったり来たりしている」箇所であり、「閾値を上回り続けている」箇所ではない。音がなっている最中の波形は0(ノイズがない場合)を中心に振動しているため、振動の過程で瞬間的な振幅が閾値を下回るサンプルがあるためだ。

一定時間以上、小音量が続く箇所を探す

上の理由から、「音がなっている箇所(残す箇所)を探す」よりも「音がなっていない箇所(カットする箇所)を探す」方がコードにした場合シンプルに実現できる。下のコードでは、0.5秒以上一定のレベル(0.05)を下回っている箇所を抽出している。

min_silence_duration = 0.5

silences = []
prev = 0
entered = 0
for i, v in enumerate(b):
  if prev == 1 and v == 0: # enter silence
    entered = i
  if prev == 0 and v == 1: # exit silence
    duration = (i - entered) / samplerate 
    if duration > min_silence_duration:
      silences.append({"from": entered, "to": i, "suffix": "cut"})
      entered = 0
  prev = v
if entered > 0 and entered < len(b):
  silences.append({"from": entered, "to": len(b), "suffix": "cut"})

青いラインの箇所が、「無音が0.5秒以上続いている=カット候補」となる。基準となる時間を短くすればするほど、カット候補が増え、動画の長さを短縮できるが、カットが細かくなりすぎると見ていて疲れるので、今回は0.5秒とした。

瞬間的に音がなっている箇所はカットする

上のグラフでは、16秒前後や21秒前後に、一瞬だけ音がなってカットの対象外となっている箇所がある。物音や咳払いなど、会話ではない音のためにカットが途切れるのは見にくいので、一定時間より短い音はカットの対象にする。

min_keep_duration = 0.2

cut_blocks = []
blocks = silences
while 1:
  if len(blocks) == 1:
    cut_blocks.append(blocks[0])
    break

  block = blocks[0]
  next_blocks = [block]
  for i, b in enumerate(blocks):
    if i == 0:
      continue
    interval = (b["from"] - block["to"]) / samplerate
    if interval < min_keep_duration:
      block["to"] = b["to"]
      next_blocks.append(b)

  cut_blocks.append(block)
  blocks = list(filter(lambda b: b not in next_blocks, blocks))

今回は0.2秒未満の音をカット対象とした。

カットする箇所を反転させて、残す箇所を決める

実際のプログラム的な編集手順としては、「不要な部分をカットする」ではなく「必要な部分をつないでいく」作業となるため、全体からカットする箇所を引いて、「残す」箇所をリストアップしていく。

keep_blocks = []
for i, block in enumerate(cut_blocks):
  if i == 0 and block["from"] > 0:
    keep_blocks.append({"from": 0, "to": block["from"], "suffix": "keep"})
  if i > 0:
    prev = cut_blocks[i - 1]
    keep_blocks.append({"from": prev["to"], "to": block["from"], "suffix": "keep"})
  if i == len(cut_blocks) - 1 and block["to"] < len(data):
    keep_blocks.append({"from": block["to"], "to": len(data), "suffix": "keep"})

残す箇所を元の動画から切り出す

元動画から、残す箇所を一個ずつ切り出していき、最後につないで一つの動画にする。

import time
mov_file = os.path.join("/gdrive", "My Drive", "audio-cut-exp", "navi_a.mov")

out_dir = os.path.join("/gdrive", "My Drive", "audio-cut-exp", "{}".format(int(time.time())))
os.mkdir(out_dir)
for i, block in enumerate(all_blocks):
  fr = block["from"] / samplerate
  to = block["to"] / samplerate
  duration = to - fr
  out_path = os.path.join(out_dir, "{:2d}_{}.mov".format(i, block["suffix"]))
  !ffmpeg -ss {fr} -i "{mov_file}" -t {duration} "{out_path}"

素直に無音以外をつなぎ合わせるとこのような動画となる。不要なシーンはカットされたが、唐突に音が繋がるため、特に後半部分は何が起きているのかわからない。いわゆる「間」もなくなってしまった。

残す箇所の前後に余白を加えて「間」を作る

カットするシーンの前後を余分につなぎ合わせることで、唐突な感じを軽減させ「間」を作ることができる。

padding_time = 0.2

...

  fr = max(block["from"] / samplerate - padding_time, 0)
  to = min(block["to"] / samplerate + padding_time, len(data) / samplerate)
  duration = to - fr

  out_path = os.path.join(out_dir, "{:2d}_{}.mov".format(i, block["suffix"]))
  !ffmpeg -ss {fr} -i "{mov_file}" -t {duration} "{out_path}"

カットする前後を0.2秒ずつ余分につなぎ合わせた場合。間ができて自然になった。余白が長くなれば長くなるほど自然になるが、その分動画は長くなりテンポは悪くなる。

まとめ/考察

シンプルなアルゴリズムにしては、比較的キレイに不要なシーンのカットができた。他の動画でも試してみたが、閾値の調整をすればどの動画でも比較的うまく動いた。

閾値の調整が自動化できればベストだが、満足できるレベルでの自動化は若干難易度が高そうだ。また、音量バランスにばらつきがある場合や、ノイズが含まれている場合は精度が落ちてしまうため、それらに対応するにはもう少し高度なアルゴリズム、機械学習との併用を検討する必要がありそうだ。

追記

2020.07.11 – macOSアプリとしてリリースしました

Pocket

「動画の無音部分を自動でカットする」への3件のフィードバック

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です