インストールコマンド
npm install @ffmpeg/core
コピーコマンド
npm install @ffmpeg/ffmpeg @ffmpeg/core
PowerShell でコピー:
cp -Recurse node_modules/@ffmpeg/core/dist public/ffmpeg-core
Vue + @ffmpeg/ffmpeg の適切なインスタンス管理と load タイミング
Vue コンポーネントで @ffmpeg/ffmpeg を使用する際、createFFmpeg() や load() のタイミング・使い方によってエラーや読み込み失敗が起きることがあります。
以下に正しい実装例と注意点をまとめます。
❗ 問題点
1. mount 前に load() を呼ぶと失敗する
- Vue のライフサイクルで DOM や依存ライブラリが準備できていない状態で呼ぶと失敗する。
2. 同じインスタンスで複数回 load() を呼ぶと失敗する
- load() は非同期処理で内部的に wasm ファイルを読み込むため、何度も呼ぶと競合してエラーになる可能性がある。
✅ 対処方法
✔️ load() は 1 回だけ実行する
✔️ グローバルにインスタンスを管理する
// ffmpeg.js
import { createFFmpeg } from '@ffmpeg/ffmpeg';
export const ffmpeg = createFFmpeg({ log: true });
✔️ Vue コンポーネントでの使用例
// MyComponent.vue (script setup または methods内)
import { ffmpeg } from './ffmpeg.js';
async function setupFFmpeg() {
if (!ffmpeg.isLoaded()) {
await ffmpeg.load(); // 1回だけ実行されるようにする
}
// あとは run() などで使用可能
}
🔄 補足:ffmpeg を使ったファイル変換の一例
await ffmpeg.FS('writeFile', 'input.wav', await fetchFile(file));
await ffmpeg.run('-i', 'input.wav', 'output.mp3');
const data = ffmpeg.FS('readFile', 'output.mp3');
const blob = new Blob([data.buffer], { type: 'audio/mpeg' });
📝 まとめ
| 注意点 | 対処法 |
|---|---|
| load() を複数回呼ばない | isLoaded() で確認してから呼ぶ |
| グローバルに管理する | createFFmpeg() を1回だけ使う |
| mount 前の呼び出し | setup() または mounted フックで |
2024年時点の本プロジェクトでの実装例(src/services/ffmpeg-service.ts より抜粋)
グローバルインスタンス管理・ロードの流れ
-
let globalFFmpegでグローバルインスタンスを1つだけ生成・管理 -
initializeGlobalFFmpeg()で未ロード時のみ初期化・loadを実行 -
isGlobalFFmpegLoadedフラグで多重ロードを防止 -
load()はPromiseで多重呼び出し時も1回だけ実行 - WASMファイルの存在確認やタイムアウト制御も実装
// グローバル変数
let globalFFmpeg: any = null
let isGlobalFFmpegLoaded = false
let loadPromise: Promise<void> | null = null
// 初期化関数
const initializeGlobalFFmpeg = async (): Promise<void> => {
if (globalFFmpeg && isGlobalFFmpegLoaded) return;
if (loadPromise) { await loadPromise; return; }
// ...FFmpegのimportとインスタンス生成...
loadPromise = performGlobalFFmpegLoad();
await loadPromise;
isGlobalFFmpegLoaded = true;
}
サービスクラスでのラップ
-
FFmpegServiceクラスでisLoaded/isGlobalFFmpegLoadedを判定し、load()は1回だけ実行 - 利用前に
await ffmpegService.load()でロード保証 -
processAudio等のメソッドでthis.isFFmpegLoaded()を必ずチェック
export class FFmpegService {
private isLoaded = false;
private loadAttempted = false;
get ffmpeg() { return globalFFmpeg; }
isFFmpegLoaded(): boolean { return this.isLoaded || isGlobalFFmpegLoaded; }
async load(): Promise<void> {
if (this.isLoaded || isGlobalFFmpegLoaded) return;
await initializeGlobalFFmpeg();
this.isLoaded = true;
}
async processAudio(file: File, ...) {
if (!this.isFFmpegLoaded()) throw new Error('FFmpeg is not loaded');
// ...処理本体...
}
}
export const ffmpegService = new FFmpegService();
Vueコンポーネントでの利用例
import { ffmpegService } from '@/services/ffmpeg-service';
onMounted(async () => {
try {
await ffmpegService.load(); // 1回だけロード
// ...以降はffmpegService.processAudio等を利用可能
} catch (e) {
// エラー処理
}
});
本実装の特徴まとめ
| ポイント | 実装内容 |
|---|---|
| load()多重呼び出し防止 | Promise/フラグで1回だけロード |
| グローバル管理 | globalFFmpegで1インスタンス |
| WASMファイル存在確認 | ファイルアクセス/タイムアウト制御あり |
| 利用前のロード保証 | サービスクラスでisLoaded判定 |
| フォールバック処理 | WASM失敗時はAudioContext等で代替 |
🔧 FFmpeg.wasm読み込み方法の詳細
1. 基本的な読み込みパターン
パターンA: 直接import + createFFmpeg
import { createFFmpeg } from '@ffmpeg/ffmpeg';
const ffmpeg = createFFmpeg({
log: true,
corePath: '/ffmpeg/ffmpeg-core.js' // カスタムパス
});
// 読み込み
await ffmpeg.load();
パターンB: 動的import + グローバル管理
// グローバル変数で管理
let globalFFmpeg: any = null;
let isLoaded = false;
const loadFFmpeg = async () => {
if (isLoaded) return globalFFmpeg;
const { createFFmpeg } = await import('@ffmpeg/ffmpeg');
globalFFmpeg = createFFmpeg({ log: true });
await globalFFmpeg.load();
isLoaded = true;
return globalFFmpeg;
};
2. 本プロジェクトでの実装詳細
2.1 サービスクラス構造
// src/services/ffmpeg-service.ts
export class FFmpegService {
private isLoaded = false;
private loadAttempted = false;
// グローバルインスタンスへのアクセス
get ffmpeg() {
return globalFFmpeg;
}
// 読み込み状態チェック
isFFmpegLoaded(): boolean {
return this.isLoaded || isGlobalFFmpegLoaded;
}
// 読み込み実行(1回のみ)
async load(): Promise<void> {
if (this.isLoaded || isGlobalFFmpegLoaded) return;
if (this.loadAttempted) {
// 既に読み込み試行中の場合、完了を待つ
while (!this.isLoaded && !isGlobalFFmpegLoaded) {
await new Promise(resolve => setTimeout(resolve, 100));
}
return;
}
this.loadAttempted = true;
await initializeGlobalFFmpeg();
this.isLoaded = true;
}
}
2.2 グローバル初期化関数
const initializeGlobalFFmpeg = async (): Promise<void> => {
// 既にロード済みの場合はスキップ
if (globalFFmpeg && isGlobalFFmpegLoaded) return;
// 読み込み中の場合は待機
if (loadPromise) {
await loadPromise;
return;
}
// 新しい読み込みを開始
loadPromise = performGlobalFFmpegLoad();
await loadPromise;
isGlobalFFmpegLoaded = true;
};
const performGlobalFFmpegLoad = async (): Promise<void> => {
try {
// 動的importでFFmpegを読み込み
const { createFFmpeg } = await import('@ffmpeg/ffmpeg');
// インスタンス作成
globalFFmpeg = createFFmpeg({
log: true,
corePath: '/ffmpeg/ffmpeg-core.js'
});
// WASMファイルの存在確認
await checkWasmFilesExist();
// 読み込み実行
await globalFFmpeg.load();
} catch (error) {
console.error('FFmpeg.wasm読み込みエラー:', error);
throw error;
}
};
3. Vueコンポーネントでの使用方法
3.1 App.vueでの初期化
// App.vue
import { ffmpegService } from './services/ffmpeg-service';
const initializeFFmpeg = async () => {
try {
console.log('App.vue: FFmpeg.wasm初期化開始...');
// サービスクラスで読み込み
if (!ffmpegService.isFFmpegLoaded()) {
await ffmpegService.load();
}
// グローバルインスタンスとして公開
if (typeof window !== 'undefined') {
(window as any).FFmpegService = ffmpegService;
}
console.log('App.vue: FFmpeg.wasm初期化成功');
} catch (error) {
console.error('App.vue: FFmpeg.wasm初期化エラー:', error);
}
};
onMounted(() => {
initializeFFmpeg();
});
3.2 個別コンポーネントでの使用
// RecordingView.vue
import { ffmpegService } from '../services/ffmpeg-service';
const convertWebmToMp3WithMetadata = async (webmBlob: Blob, title: string, duration: number) => {
try {
// 読み込み状態チェック
if (!ffmpegService.isFFmpegLoaded()) {
console.log('FFmpeg.wasmを読み込み中...');
await ffmpegService.load();
}
const ffmpeg = ffmpegService.ffmpeg;
// ファイル変換処理
const webmData = await webmBlob.arrayBuffer();
ffmpeg.writeFile('input.webm', new Uint8Array(webmData));
await ffmpeg.exec([
'-i', 'input.webm',
'-c:a', 'libmp3lame',
'-b:a', '128k',
'output.mp3'
]);
const mp3Data = ffmpeg.readFile('output.mp3');
return new Blob([mp3Data], { type: 'audio/mp3' });
} catch (error) {
console.error('FFmpeg.wasm処理エラー:', error);
throw error;
}
};
4. エラーハンドリングとフォールバック
4.1 読み込み失敗時の処理
const convertWithFallback = async (webmBlob: Blob) => {
try {
// FFmpeg.wasmで変換を試行
return await convertWebmToMp3WithMetadata(webmBlob, filename, duration);
} catch (error) {
console.warn('FFmpeg.wasm変換失敗、WebMファイルのまま保存:', error);
// フォールバック: WebMファイルのまま返す
const webmFilename = filename.endsWith('.webm') ? filename : filename + '.webm';
return { blob: webmBlob, filename: webmFilename };
}
};
4.2 読み込み状態の監視
// 読み込み状態を監視するイベントシステム
const ffmpegLoadingStatus = ref<'loading' | 'success' | 'error' | null>(null);
const checkFFmpegStatus = () => {
const event = new CustomEvent('check-ffmpeg-status');
window.dispatchEvent(event);
};
// 定期的に状態をチェック
const statusInterval = setInterval(checkFFmpegStatus, 1000);
// 状態変更イベントのリスナー
const handleFFmpegStatusChange = (event: CustomEvent) => {
ffmpegLoadingStatus.value = event.detail.status;
};
window.addEventListener('ffmpeg-status-change', handleFFmpegStatusChange);
5. パフォーマンス最適化
5.1 遅延読み込み
// 必要になるまで読み込みを遅延
const loadFFmpegOnDemand = async () => {
if (!ffmpegService.isFFmpegLoaded()) {
// ユーザーに読み込み中であることを通知
showLoadingMessage('音声処理エンジンを読み込み中...');
await ffmpegService.load();
hideLoadingMessage();
}
};
5.2 メモリ管理
// 使用後のクリーンアップ
const cleanupFFmpeg = () => {
if (ffmpegService.ffmpeg) {
// ファイルシステムのクリア
ffmpegService.ffmpeg.FS('unlink', 'input.webm');
ffmpegService.ffmpeg.FS('unlink', 'output.mp3');
}
};
6. デバッグとトラブルシューティング
6.1 読み込み状態の確認
const debugFFmpegStatus = () => {
console.log('=== FFmpeg.wasm状態確認 ===');
console.log('グローバルインスタンス:', (window as any).FFmpeg);
console.log('FFmpegService:', (window as any).FFmpegService);
console.log('isFFmpegLoaded:', ffmpegService.isFFmpegLoaded());
console.log('ffmpegインスタンス:', ffmpegService.ffmpeg);
console.log('ロード状態:', ffmpegService.ffmpeg?.loaded);
console.log('エラー:', ffmpegService.ffmpeg?.loadError);
};
6.2 よくある問題と解決策
| 問題 | 原因 | 解決策 |
|---|---|---|
load()が複数回呼ばれる |
コンポーネントの再マウント | グローバルフラグで制御 |
| WASMファイルが見つからない | パス設定ミス |
corePathを正しく設定 |
| メモリ不足エラー | 大きなファイル処理 | ファイルサイズ制限を設定 |
| 読み込みタイムアウト | ネットワーク遅延 | タイムアウト時間を延長 |
7. 本プロジェクトでの実装の利点
- グローバル管理: アプリ全体で1つのインスタンスを共有
- 多重読み込み防止: Promiseとフラグで確実に1回のみ読み込み
- エラーハンドリング: 読み込み失敗時の適切なフォールバック
- 状態監視: 読み込み状態をリアルタイムで監視
- メモリ効率: 不要なインスタンス生成を防止
- デバッグ支援: 詳細なログとデバッグ機能
この実装により、FFmpeg.wasmの安定した読み込みと利用が可能になります。
📝 2025年1月の修正内容
🔧 録音方式の変更(WebM継続録音)
変更前: チャンク録音方式
- 5分間隔でチャンク分割
- IndexedDBを使用したチャンク保存
- 複雑なメモリ管理機能
変更後: WebM継続録音方式
- WebM形式で1つのファイルに継続録音
- 録音停止時にMP3変換とメタデータ追加
- シンプルで安定した録音方式
技術仕様
// RecordRTC設定
recorder.value = new RecordRTC(mediaStream, {
type: 'video', // WebM形式で録音
mimeType: 'video/webm',
sampleRate: 44100,
desiredSampRate: 44100,
recorderType: RecordRTC.MediaStreamRecorder,
numberOfAudioChannels: 1,
bufferSize: 4096,
audioBitsPerSecond: 128000,
timeSlice: 1000, // 1秒ごとにデータを取得
ondataavailable: (blob: Blob) => {
console.log('録音データ受信:', blob.size)
currentRecordingSize.value += blob.size
}
})
🔧 FFmpegServiceのグローバル公開
App.vueでの修正
// FFmpeg.wasm初期化成功時にグローバル公開
ffmpegLoadingStatus.value = 'success'
console.log('App.vue: FFmpeg.wasm初期化成功')
// FFmpegServiceをグローバルに公開
if (typeof window !== 'undefined') {
(window as any).FFmpegService = ffmpegService
console.log('App.vue: FFmpegServiceをグローバルに公開しました')
}
RecordingView.vueでの使用
// FFmpegServiceを使用してFFmpeg.wasmにアクセス
const ffmpegService = (window as any).FFmpegService
console.log('FFmpegService状態確認:', {
ffmpegService: !!ffmpegService,
isLoaded: ffmpegService?.isFFmpegLoaded(),
ffmpeg: !!ffmpegService?.ffmpeg,
windowKeys: Object.keys(window).filter(key => key.includes('FFmpeg'))
})
if (!ffmpegService) {
console.warn('FFmpegServiceがグローバルに公開されていません。WebM形式のまま保存します。')
return webmBlob
}
if (!ffmpegService.isFFmpegLoaded()) {
console.warn('FFmpegServiceが読み込みされていません。WebM形式のまま保存します。')
return webmBlob
}
🔧 読み込み待機機能の追加
MP3ダウンロード時の待機処理
// FFmpegServiceの読み込みを待つ
let ffmpegService = (window as any).FFmpegService
let retryCount = 0
const maxRetries = 10
while (!ffmpegService && retryCount < maxRetries) {
console.log(`FFmpegService読み込み待機中... (${retryCount + 1}/${maxRetries})`)
await new Promise(resolve => setTimeout(resolve, 500))
ffmpegService = (window as any).FFmpegService
retryCount++
}
if (!ffmpegService) {
console.warn('FFmpegServiceの読み込みがタイムアウトしました。WebM形式でダウンロードします。')
downloadAudioFile(webmBlob, filename)
return
}
🔧 WebM→MP3変換とメタデータ追加
変換処理
// MP3変換とメタデータ追加
await ffmpeg.exec([
'-i', 'input.webm',
'-c:a', 'libmp3lame', // LAMEエンコーダーを使用
'-b:a', '128k', // 固定ビットレート128k
'-ar', '44100', // サンプルレート44.1kHz
'-ac', '1', // モノラル
'-write_xing', '1', // Xingヘッダーを有効化(シーク機能)
'-id3v2_version', '3', // ID3v2.3タグを使用
'-write_id3v1', '1', // ID3v1タグも追加
// 基本メタデータ
'-metadata', `title=${filename.value || '録音ファイル'}`,
'-metadata', `artist=東広島基幹相談支援センター`,
'-metadata', `album=相談記録`,
'-metadata', `date=${recordingDate}`,
'-metadata', `year=${now.getFullYear()}`,
'-metadata', `recording_time=${recordingTimeStr}`,
'-metadata', `duration=${formatTime(recordingTime.value)}`,
'-metadata', `file_size=${formatFileSize(webmBlob.size)}`,
'-metadata', `sample_rate=44100`,
'-metadata', `bitrate=128k`,
'-metadata', `channels=1`,
'-metadata', `recording_session_id=${Date.now()}`,
'-metadata', `processing_timestamp=${now.toISOString()}`,
// 技術情報
'-metadata', `encoder=FFmpeg.wasm + LAME`,
'-metadata', `encoding_settings=MP3 128k CBR Mono 44.1kHz`,
'-metadata', `audio_format=MPEG-1 Layer 3`,
'-metadata', `source_format=WebM Opus`,
// システム情報
'-metadata', `system_info=東広島基幹相談支援センター 相談記録システム`,
'-metadata', `software_version=1.0.0`,
'-metadata', `browser_info=${navigator.userAgent}`,
'-metadata', `platform=${navigator.platform}`,
// 説明文
'-metadata', `comment=WebM形式で録音後、MP3に変換された音声ファイル。シーク機能対応。`,
'-metadata', `description=相談記録用音声ファイル。モノラル録音、128kbps固定ビットレート。`,
'output.mp3'
])
🔧 ローカルダウンロード機能の改善
MP3形式ダウンロード
// ローカルダウンロード機能(MP3形式)
const downloadMp3File = async (webmBlob: Blob, filename: string) => {
try {
console.log('MP3形式でのダウンロード開始...')
// FFmpegServiceの読み込みを待つ
let ffmpegService = (window as any).FFmpegService
let retryCount = 0
const maxRetries = 10
while (!ffmpegService && retryCount < maxRetries) {
console.log(`FFmpegService読み込み待機中... (${retryCount + 1}/${maxRetries})`)
await new Promise(resolve => setTimeout(resolve, 500))
ffmpegService = (window as any).FFmpegService
retryCount++
}
if (!ffmpegService) {
console.warn('FFmpegServiceの読み込みがタイムアウトしました。WebM形式でダウンロードします。')
downloadAudioFile(webmBlob, filename)
return
}
// WebM→MP3変換
const mp3Blob = await convertWebmToMp3WithMetadata(webmBlob)
// ダウンロード実行
const url = URL.createObjectURL(mp3Blob)
const a = document.createElement('a')
a.href = url
a.download = `${filename}.mp3`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
console.log('MP3形式でのローカルダウンロード完了:', filename)
} catch (error) {
console.error('MP3形式でのローカルダウンロードエラー:', error)
alert('MP3形式でのダウンロードに失敗しました。')
}
}
📊 修正による改善点
| 項目 | 改善前 | 改善後 |
|---|---|---|
| 録音方式 | チャンク録音(複雑) | WebM継続録音(シンプル) |
| メモリ使用量 | 高(チャンク管理) | 低(単一ファイル) |
| FFmpegアクセス | 直接インスタンス作成 | FFmpegService経由 |
| 読み込み待機 | なし | 最大5秒間待機 |
| ダウンロード形式 | WebM形式 | MP3形式(変換後) |
| エラーハンドリング | 基本的 | 詳細なデバッグ情報 |
🎯 今後の課題
- FFmpegService読み込みタイミング: より確実な読み込み保証
- 変換処理の最適化: 大きなファイルの処理時間短縮
- メタデータの拡張: より詳細な録音情報の追加
- エラー回復機能: 変換失敗時の自動リトライ