複数カメラ同期(頭出し)を「ピエゾブザーの音」でやる:設計・検出・切り出し
狙い:複数の民生カメラ(スマホ・アクションカム等)の録画を、冒頭に鳴らした「ブザー音」を基準に同期し、
ffmpegで同じ時刻から動画を抜き出す(頭出し)アイデアの検討
前提:民生品マイクは高周波(10kHz超)を拾いにくい/オートゲイン(AGC)やノイズ抑制が勝手に入ることがある。
前提:民生品マイクは高周波(10kHz超)を拾いにくい/オートゲイン(AGC)やノイズ抑制が勝手に入ることがある。
1. 同期の基本戦略:高周波は捨てて「確実に入る帯域」で勝つ
- おすすめ周波数帯:1kHz〜4kHz(民生マイクでも拾いやすく、環境音と被りにくい)
- 避けたい帯域:8kHz〜(端末によって急に落ちる/圧縮で潰れる/指向性で変動する)
- 同期マーカーは「単発1回」より「パターン」:誤検出対策に、例:0.0s, 0.4s, 1.2sの3回ビープ(間隔にクセを付ける)
- 音が不安定なら視覚も併用:ブザーと同時にLED点滅(映像フレームで合わせる)も強い
現実の罠:スマホ等はAGC/ノイズ抑制で「最初のビープだけ小さくなる」「立ち上がりが丸くなる」ことがある。
対策はビープを短くしすぎない(100〜300ms)、3回パターンにする、2〜4kHzの帯域で検出する。
対策はビープを短くしすぎない(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秒早め)から切り出すと揃う。
つまり、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 をステップして鳴らし、上のスクリプトで「一番エネルギーが強い周波数」を自動で決める構成にすると強いかも。
