はじめに
Electron + Vue 3 で動画を再生するデスクトップアプリを開発していたとき、macOS では問題なく再生できていた動画が、Windows 環境で音ズレ・カクつきを起こす問題に遭遇しました。
具体的な症状は以下の通りです:
- 動画の再生を開始すると、映像だけが先に再生される
- 少し遅れて音声が追いかけるように再生される
- ブラウザ(Chromium)が整合性を取ろうとして映像が一時停止する
- 同期が取れた後は正常に再生される
ユーザーから見ると、再生開始直後に映像がカクッと止まる不自然な挙動になります。
最終的に 動画素材を映像(muted)と音声(Web Audio API)を完全に分離し、音声の出力を検知してから映像を開始する という力技で解決しました。AIに聞いてもなかなか解決には至らず、思い当たる方法を片っ端から試した結果こうなりました。問題で困っている方の参考になればと思い、試行錯誤の過程も含めて共有します。
環境
- Electron 39(Chromium ベース)
- Vue 3.5 + TypeScript
- electron-vite(Vite 7)
- 問題が発生したのは Windows 環境のみ(macOS では再現しない)
解決策: 映像と音声を分離 + Web Audio API
原因は、Chromium が 1 つの <video> 要素で映像と音声を同時にデコードする際、Windows 環境では音声デコードが映像に影響を与える ことでした。
そこで 映像と音声を完全に分離 するアプローチを取りました。
方針
- 動画ファイルを映像のみと 音声のみに分離
-
<video>はmuted属性を付けて映像専用にする(音に合わせて映像がずれるため) - 音声は Web Audio API の
AudioBufferSourceNodeで再生する - Web Audio API の波形解析機能で音声信号を検知し、音声が実際に出力された瞬間に映像をスタートする
音声のプリロード
アプリ起動時に全音声ファイルを AudioBuffer として事前読み込みしておきます。
let audioCtx: AudioContext | null = null;
const audioBuffers = new Map<string, AudioBuffer>();
let silentOscillator: OscillatorNode | null = null;
function getAudioContext(): AudioContext {
if (!audioCtx) {
audioCtx = new AudioContext();
// AudioContext がサスペンドされるのを防ぐサイレントオシレーター
const gainNode = audioCtx.createGain();
gainNode.gain.value = 0;
gainNode.connect(audioCtx.destination);
silentOscillator = audioCtx.createOscillator();
silentOscillator.connect(gainNode);
silentOscillator.start();
}
return audioCtx;
}
async function loadAudioBuffer(url: string): Promise<void> {
if (audioBuffers.has(url)) return;
const ctx = getAudioContext();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
audioBuffers.set(url, audioBuffer);
}
// アプリ起動時に全音声を読み込み
onMounted(async () => {
getAudioContext();
const allAudioUrls = [QUESTION_AUDIO_URL, ...getAllResponseAudioUrls()];
await Promise.all(allAudioUrls.map((url) => loadAudioBuffer(url)));
});
ポイントは 無音の音声を常に流し続けている 部分です。AudioContext は一定時間音声出力がないとブラウザによって自動的に一時停止されることがあります。音量ゼロの音を裏で流し続けることで、いつでもすぐに音声を再生できる状態を保っています。
同期再生の仕組み
核となる playWithSync 関数です。
const AUDIO_DETECT_TIMEOUT_MS = 3000;
function playWithSync(videoEl: HTMLVideoElement, audioUrl: string): void {
const ctx = getAudioContext();
if (ctx.state === "suspended") {
ctx.resume();
}
const buffer = audioBuffers.get(audioUrl);
if (!buffer) {
// フォールバック: バッファがなければ映像だけ再生
videoEl.currentTime = 0;
videoEl.play();
return;
}
stopAudio();
// 波形データを取得して「音が鳴っているか」を監視する
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
// 音声の再生を開始
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(analyser);
analyser.connect(ctx.destination);
source.start(0);
videoEl.currentTime = 0;
let timeoutId: ReturnType<typeof setTimeout>;
let rafId: number;
const startVideo = (): void => {
clearTimeout(timeoutId);
videoEl.play();
};
// 音声信号を検知したら映像をスタート
const checkAudio = (): void => {
analyser.getByteTimeDomainData(dataArray);
const hasSignal = dataArray.some((v) => Math.abs(v - 128) > 2);
if (hasSignal) {
startVideo();
return;
}
rafId = requestAnimationFrame(checkAudio);
};
rafId = requestAnimationFrame(checkAudio);
// 3秒以内に音声を検知できなければ映像を強制スタート
timeoutId = setTimeout(() => {
cancelAnimationFrame(rafId);
startVideo();
}, AUDIO_DETECT_TIMEOUT_MS);
}
処理の流れを図にするとこうなります:
音声再生開始 (source.start)
│
▼
波形データを監視して「音が鳴ったか?」を毎フレーム確認
│
├─ 音を検知 → 映像再生開始 (video.play)
│
└─ 3秒タイムアウト → 映像を強制スタート(フォールバック)
getByteTimeDomainData は音声の波形データを 0〜255 の数値で取得する関数です。無音のときは全て 128(中央値)になるので、Math.abs(v - 128) > 2 で「実際に音が鳴り始めた」ことを判定しています。
テンプレート
映像側は全て muted 属性付きで、音声は一切持ちません。
<template>
<div class="main-view">
<video
ref="questionVideoRef"
:src="QUESTION_VIDEO_URL"
muted
preload="auto"
class="video"
:class="{ active: phase === 'question' || phase === 'waiting' }"
@ended="onQuestionVideoEnded"
/>
<video
v-for="src in responseVideoUrls"
:key="src"
:ref="(el) => setResponseVideoRef(el as HTMLVideoElement, src)"
:src="src"
muted
preload="auto"
class="video"
:class="{ active: phase === 'response' && activeResponseVideoSrc === src }"
@ended="onResponseVideoEnded"
/>
</div>
</template>
実装のポイント
なぜ映像と音声を分離すると解決するのか
Chromium の <video> 要素は、映像トラックと音声トラックを同一パイプラインでデコードします。Windows 環境では音声デコーダの初期化に時間がかかるケースがあり、その間に映像だけが先行して再生されてしまいます。その後、ブラウザが音声との同期を取ろうとして映像を一時停止するため、カクつきとして見えていました。
映像を muted にすると、ブラウザは音声デコードを完全にスキップするため、映像の再生がスムーズになります。音声を Web Audio API で独立して再生することで、双方が干渉せずに動作します。
なぜ無音の音を流し続けるのか
const gainNode = audioCtx.createGain();
gainNode.gain.value = 0; // 音量ゼロ
gainNode.connect(audioCtx.destination);
silentOscillator = audioCtx.createOscillator();
silentOscillator.connect(gainNode);
silentOscillator.start();
AudioContext はしばらく音声出力がないと、ブラウザが省リソースのために自動で一時停止します。一時停止状態からの復帰には resume() が必要ですが、復帰にかかる時間は不確定で、音声再生が遅れる原因になります。
音量ゼロの音(OscillatorNode)を常に流しておくことで、AudioContext の一時停止を防ぎ、source.start() ですぐに音が出る状態を保っています。
フォールバックタイムアウト
timeoutId = setTimeout(() => {
cancelAnimationFrame(rafId);
startVideo();
}, AUDIO_DETECT_TIMEOUT_MS); // 3000ms
音声ファイルが破損していたり、先頭に長い無音区間がある場合、いつまでも音を検知できず映像が始まらないという事態を防ぐための保険です。3 秒あれば通常の音声ファイルは確実に出力が始まっているので、この値にしています。
補足: 試したけど効果がなかったこと
解決策にたどり着くまでに試したアプローチも共有しておきます。動画の読み込みや DOM のライフサイクルは原因ではありませんでした。
| アプローチ | やったこと | 結果 |
|---|---|---|
| 全動画の事前読み込み + opacity 切り替え |
v-if による DOM 生成/削除を廃止し、全 <video> を preload="auto" で常に DOM に配置 |
画面切り替え時のチラつきは改善したが、音ズレには効果なし |
| KeepAlive で DOM 保持 | Vue の <KeepAlive> で動画コンポーネントを画面遷移をまたいで保持 |
効果なし |
| readyState 確認後に再生 |
readyState >= HAVE_FUTURE_DATA を確認してから play() を呼ぶ |
効果なし。preload="auto" で読み込み済みのため待ちが発生しなかった |
| muted → unmute 方式 |
muted で再生を開始し、映像が安定してから unmute する |
改善するが不安定。遅延を大きくすると無音の瞬間が生まれる |
まとめ
「動画のカクつき」と聞くと読み込みや DOM のライフサイクルを疑いがちですが、今回の根本原因は Chromium の音声デコーダが映像パイプラインに影響を与える というブラウザ内部の挙動でした。
映像と音声を分離し、Web Audio API で「音が実際に鳴った瞬間」を検知してから映像をスタートすることで、Windows 環境でもカクつきのないスムーズな再生を実現できました。
同じ問題に遭遇した方の参考になれば幸いです。