複数カメラ同期(頭出し)を「ピエゾブザーの音」でやる:設計・検出・切り出し

ピエゾブザー同期(頭出し)設計 + ピーク検出スクリプト + ffmpegで同期点から動画抜き出し
狙い:複数の民生カメラ(スマホ・アクションカム等)の録画を、冒頭に鳴らした「ブザー音」を基準に同期し、 ffmpegで同じ時刻から動画を抜き出す(頭出し)アイデアの検討
前提:民生品マイクは高周波(10kHz超)を拾いにくい/オートゲイン(AGC)やノイズ抑制が勝手に入ることがある。

1. 同期の基本戦略:高周波は捨てて「確実に入る帯域」で勝つ

  • おすすめ周波数帯:1kHz〜4kHz(民生マイクでも拾いやすく、環境音と被りにくい)
  • 避けたい帯域:8kHz〜(端末によって急に落ちる/圧縮で潰れる/指向性で変動する)
  • 同期マーカーは「単発1回」より「パターン」:誤検出対策に、例:0.0s, 0.4s, 1.2sの3回ビープ(間隔にクセを付ける)
  • 音が不安定なら視覚も併用:ブザーと同時にLED点滅(映像フレームで合わせる)も強い
現実の罠:スマホ等はAGC/ノイズ抑制で「最初のビープだけ小さくなる」「立ち上がりが丸くなる」ことがある。
対策はビープを短くしすぎない(100〜300ms)3回パターンにする2〜4kHzの帯域で検出する

2. ピエゾブザー設計(単音を確実に鳴らす:実装が詰まらない版)

2.1 ピエゾの種類(ここ間違えると全部ズレる)

  • アクティブブザー:DC給電で勝手に一定音。同期用途なら楽(ただし周波数固定が多い)。
  • パッシブ(圧電素子):PWM等で交流を入れて鳴らす。周波数を選べる(同期の最適化に強い)。

2.2 周波数選定:共振点を狙うが「録れる帯域」が最優先

  • 圧電は共振周波数(例:2.7kHz/4kHz)があり、そこが一番大きい。
  • ただし同期は「最大音量」より「全カメラに同じ波形で入る」ことが大事。
  • 結論:まず2k〜4kHzでテスト → 一番安定して入る周波数に固定。

2.3 駆動回路の考え方(民生マイクに確実に入れる音圧を作る)

  • 片側駆動(簡単):MCU PWM → トランジスタ(ローサイド) → ピエゾ → VCC
  • 差動駆動(強い):ピエゾ両端を逆相で振る(実質電圧差が増えて音圧が上がる)
差動駆動の旨み:片側を0〜Vで振るより、両端を逆相にして±V相当にすると、 同じVCCでも音量が上がり、同期が安定する(遠いカメラにも入りやすい)。

2.4 MCU側の単音生成(PWM周波数=音の周波数)

  • 例:3000Hz, デューティ50%のPWMを100〜300ms出す。
  • パターン例:200ms ON / 200ms OFF / 200ms ON / 600ms OFF / 200ms ON

3. 同期点(ビープ)の検出:ピーク周波数 + エネルギー立ち上がりで取る

方針:各動画から音声をWAVに抽出 → 2〜4kHz付近を見てビープの時刻を出す。
同期:基準カメラのビープ時刻との差分をオフセット(秒)として算出。

3.1 ffmpegで音声を抽出(全カメラで条件を揃える)

# 例:動画からモノラル48kHzのWAVを抽出(圧縮音声の癖を減らす)
ffmpeg -y -i camA.mp4 -vn -ac 1 -ar 48000 -f wav camA.wav
ffmpeg -y -i camB.mp4 -vn -ac 1 -ar 48000 -f wav camB.wav
ffmpeg -y -i camC.mp4 -vn -ac 1 -ar 48000 -f wav camC.wav

# (任意)音量差が激しい場合は正規化してから解析(AGCの影響を薄める)
ffmpeg -y -i camA.wav -af loudnorm camA_norm.wav

3.2 Python:ビープの「開始時刻」を推定するスクリプト(帯域エネルギー + しきい値)

注意:これは「絶対SPL(dB)」は出しません。民生マイクは校正されてないので、正しいdB SPLは出せない。
ただし「どの周波数が一番強い」「どの時刻に鳴った」は十分取れる(同期目的ならこれで勝ち)。
# beep_detect.py
# 依存: numpy scipy soundfile
#   pip install numpy scipy soundfile

import numpy as np
import soundfile as sf
from scipy.signal import butter, sosfilt, get_window

PREF_20UPA = 20e-6  # 参考: SPL基準 (校正が無いと使わない)

def bandpass(x, fs, f1, f2, order=6):
    sos = butter(order, [f1, f2], btype="bandpass", fs=fs, output="sos")
    return sosfilt(sos, x)

def moving_rms(x, win):
    # 短時間RMS(エネルギー包絡)
    x2 = x * x
    kernel = np.ones(win, dtype=np.float64) / win
    return np.sqrt(np.convolve(x2, kernel, mode="same") + 1e-12)

def estimate_peak_freq(x, fs, fmin=500, fmax=8000):
    # ビープ周波数の当て(全体FFTで最大ピーク)
    w = get_window("hann", x.size, fftbins=True)
    X = np.fft.rfft(x * w)
    freqs = np.fft.rfftfreq(x.size, d=1/fs)
    mag = np.abs(X)
    idx = np.where((freqs >= fmin) & (freqs <= fmax))[0]
    i = idx[np.argmax(mag[idx])]
    return float(freqs[i])

def detect_beep_time(wav_path, band=(2000, 4000), rms_ms=10, thresh_k=6.0, min_gap_ms=150):
    x, fs = sf.read(wav_path, dtype="float64")
    if x.ndim == 2:
        x = x[:, 0]

    # 対象帯域へ
    y = bandpass(x, fs, band[0], band[1], order=6)

    # 包絡
    win = max(1, int(fs * (rms_ms / 1000.0)))
    env = moving_rms(y, win)

    # しきい値:中央値 + k * MAD(ロバスト)
    med = np.median(env)
    mad = np.median(np.abs(env - med)) + 1e-12
    thr = med + thresh_k * 1.4826 * mad

    # 立ち上がり検出(閾値を初めて超えたサンプル)
    idx = np.where(env > thr)[0]
    if idx.size == 0:
        return None

    # 雑な誤検出抑制:最初の検出点の直前を少し巻き戻す
    i0 = int(idx[0])
    # 立ち上がりを探す(環境音からの遷移点)
    back = int(fs * 0.05)  # 50ms
    j0 = max(0, i0 - back)
    # 立ち上がりの微分で早い点を拾う(簡易)
    d = np.diff(env[j0:i0+1], prepend=env[j0])
    k = np.argmax(d)
    t = (j0 + k) / fs
    return float(t)

if __name__ == "__main__":
    import sys
    wav = sys.argv[1]
    # まず全体からピーク周波数の目安を取る(デバッグ用)
    x, fs = sf.read(wav, dtype="float64")
    if x.ndim == 2:
        x = x[:, 0]
    f0 = estimate_peak_freq(x, fs, 500, 8000)
    print(f"[info] estimated peak ~ {f0:.1f} Hz")

    t = detect_beep_time(wav, band=(2000, 4000), rms_ms=10, thresh_k=6.0)
    print(f"[result] beep_time = {t}")

使い方例:python beep_detect.py camA_norm.wav → beep_time(秒)が出る。
カメラごとに beep_time を出して、基準カメラとの差分が同期オフセット(camBは+0.183s遅れ、など)。

4. ffmpegで「頭位置」から動画を抜き出す(同期オフセットを反映)

4.1 オフセットが出たら、揃えた開始時刻で切る

例:基準camAのビープが 12.340s、camBが 12.523s なら、camBは0.183s遅れて録れている
つまり、camBは -0.183s(= 0.183秒早め)から切り出すと揃う。
# 例:基準camAはそのまま、camBは0.183秒だけ早めて頭出しを揃える(再エンコード無し)
# ※ -ss の位置で精度が変わる。正確さ優先なら -ss を -i の後ろへ(ただし遅い)
ffmpeg -y -ss 0.000 -i camA.mp4 -t 60 -c copy camA_sync.mp4
ffmpeg -y -ss 0.183 -i camB.mp4 -t 60 -c copy camB_sync.mp4

# さらにフレーム精度を優先(再エンコードする:編集耐性は上がる)
ffmpeg -y -ss 0.183 -i camB.mp4 -t 60 -c:v libx264 -crf 18 -preset veryfast -c:a aac -b:a 192k camB_sync_reenc.mp4

4.2 「ビープ時刻を0秒」に正規化してから切る(管理が楽)

# beep_time を引いた地点から開始して、ビープの瞬間がタイムライン0になるようにする
# 例:camA beep_time=12.340 → -ss 12.340
ffmpeg -y -ss 12.340 -i camA.mp4 -c copy camA_beep0.mp4
ffmpeg -y -ss 12.523 -i camB.mp4 -c copy camB_beep0.mp4
ffmpeg -y -ss 12.411 -i camC.mp4 -c copy camC_beep0.mp4

# 以降は全部「同じ時刻」で切れば揃う
ffmpeg -y -ss 5.000 -i camA_beep0.mp4 -t 30 -c copy A_05to35.mp4
ffmpeg -y -ss 5.000 -i camB_beep0.mp4 -t 30 -c copy B_05to35.mp4

5. 同期の信頼性を上げるための実務メモ(民生品の制約込み)

  • ビープは短すぎるとAGCに負ける:100〜300msが無難。
  • 3回パターンにする:単発だと拍手・金属音で誤検出する。間隔にクセを付けると強い。
  • 帯域を固定する:2〜4kHzで検出しておけば、高域が死んだ端末でも動く。
  • 距離・向きがバラバラなら差動駆動で音圧を稼ぐ:「入らないカメラ」が出ると全敗。
  • どうしても音が信用できないとき:LEDフラッシュを映像にも入れる(最後の保険)。
重要:「音圧(dB SPLの絶対値)」は、校正マイク/校正器が無い限り真面目に出せない。
ただし同期目的なら、必要なのは時刻(ピークの位置)なので、相対値で十分。

6. 次の一手(ここから自動化)

  • Python側で各cam.wavのbeep_timeをまとめて読み、基準との差分をCSVに出す
  • そのCSVを元に、ffmpegコマンドを自動生成して一括で同期版動画を作る
  • ビープを「3回パターン」にして、検出も3点の相関(パターン照合)にすると誤検出がさらに減る

もし「ビープの周波数を可変にしたい(最も安定する帯域を現場で自動探索したい)」なら、
MCU側で 1.5k〜4.5kHz をステップして鳴らし、上のスクリプトで「一番エネルギーが強い周波数」を自動で決める構成にすると強いかも。

コメントを残す

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

CAPTCHA