macOSアプリで動画から文字起こし用の音声を取り出す必要があり、libmpv をヘッドレス実行してWAVを書き出す実装を入れました。
この記事では、動画プレーヤーアプリで使っている実装から、音声抽出部分だけを切り出します。
やることは次の1点です。
- ローカル動画を
libmpvで開き、16kHz mono WAVとして一時ファイルに書き出す
なぜAVFoundationではなくlibmpvを使うか
mp4 / mov だけを相手にするなら、AVAssetReader などで処理できます。
ただ、動画プレーヤー側では mkv / webm など、AVFoundationがそのまま開けないコンテナも扱いたいケースがあります。文字起こし用の音声抽出だけAVFoundationに寄せると、
- 再生はできるのに音声抽出はできない
- 形式ごとに別の抽出経路を持つ必要がある
という状態になります。
そこで、再生にも使っている libmpv を短命のヘッドレスインスタンスとして起動し、ao=pcm でWAVを書き出す形にしました。
mpvの設定
音声だけ取り出すので、映像出力は完全に止めます。
| option | 値 | 目的 |
|---|---|---|
terminal |
no |
ターミナル出力を抑える |
config |
no |
ユーザーのmpv設定を読まない |
msg-level |
all=no |
ログを抑える |
vid |
no |
映像をデコードしない |
vo |
null |
映像出力を持たない |
ao |
pcm |
音声をPCMファイルに出す |
ao-pcm-file |
出力先path | WAVの出力先 |
ao-pcm-waveheader |
yes |
WAVヘッダを付ける |
audio-samplerate |
16000 |
文字起こし向けに16kHzへ |
audio-channels |
mono |
monoへ |
vid=no と vo=null を入れているのは、動画を表示したいわけではないからです。UI用の再生とは別に、音声抽出専用のmpvを作ってすぐ破棄します。
実装
実装は次のようにしています。
import Foundation
import Libmpv
enum AudioExtractor {
nonisolated static func extractAudio(
from url: URL,
startSec: Double? = nil,
lengthSec: Double? = nil
) -> URL? {
guard let h = mpv_create() else { return nil }
let out = FileManager.default.temporaryDirectory
.appendingPathComponent("reel-notes-\(UUID().uuidString).wav")
func opt(_ key: String, _ value: String) {
_ = mpv_set_option_string(h, key, value)
}
opt("terminal", "no")
opt("config", "no")
opt("msg-level", "all=no")
opt("vid", "no")
opt("vo", "null")
opt("ao", "pcm")
opt("ao-pcm-file", out.path)
opt("ao-pcm-waveheader", "yes")
opt("audio-samplerate", "16000")
opt("audio-channels", "mono")
if let startSec {
opt("start", String(startSec))
}
if let lengthSec {
opt("length", String(lengthSec))
}
guard mpv_initialize(h) >= 0 else {
mpv_terminate_destroy(h)
return nil
}
let args = ["loadfile", url.path]
let dup = args.map { strdup($0) }
defer { dup.forEach { free($0) } }
var cargs: [UnsafePointer<CChar>?] = dup.map { UnsafePointer($0) } + [nil]
cargs.withUnsafeMutableBufferPointer {
_ = mpv_command(h, $0.baseAddress)
}
let deadline = Date().addingTimeInterval(600)
loop: while Date() < deadline {
guard let event = mpv_wait_event(h, 1.0) else {
break
}
switch event.pointee.event_id {
case MPV_EVENT_END_FILE, MPV_EVENT_SHUTDOWN:
break loop
default:
continue
}
}
mpv_terminate_destroy(h)
guard FileManager.default.fileExists(atPath: out.path),
let size = try? FileManager.default
.attributesOfItem(atPath: out.path)[.size] as? Int,
size > 1024 else {
return nil
}
return out
}
}
mpv_wait_event で MPV_EVENT_END_FILE を待つと、WAVの書き出し完了を検知できます。
ただし、異常な入力でイベントが返らないとタスクが終わらなくなるので、実装では600秒の上限を入れています。
UIスレッドで実行しない
この処理は mpv_wait_event を回すブロッキング処理です。
SwiftUIアプリのメインアクターで直接呼ぶとUIが止まるので、呼び出し側では Task.detached に逃がしています。
let wav = await Task.detached(priority: .userInitiated) {
AudioExtractor.extractAudio(from: videoURL)
}.value
抽出できたWAVは文字起こしに渡し、使い終わったら削除します。
if let wav {
let segments = await transcribe(wav)
try? FileManager.default.removeItem(at: wav)
}
部分抽出したい場合
長い動画の一部だけを処理したい場合は、start と length を渡せます。
let wav = AudioExtractor.extractAudio(
from: url,
startSec: 60,
lengthSec: 120
)
この場合、60秒地点から120秒分だけをWAVにします。
はまった点
ao-pcm-waveheader=yes を忘れない
ao=pcm だけだと、後段のAPIが期待するWAVとして扱いにくくなります。AVAudioFile などで読む前提なら、ao-pcm-waveheader=yes を入れてWAVヘッダ付きにしておくのが安全です。
mpvのユーザー設定を読ませない
アプリ内処理として使うなら、ユーザーの mpv.conf に影響されると再現性が落ちます。
opt("config", "no")
を入れて、アプリ側で必要なオプションを明示します。
サンドボックス下ではURLの権限は別問題
この記事のコードは、渡された URL にアクセスできる前提です。
Mac App Store向けのサンドボックスアプリでは、事前に NSOpenPanel や security-scoped bookmark でアクセス権を確保してから、この抽出処理に渡す必要があります。
まとめ
libmpv を再生エンジンとして組み込んでいるアプリなら、音声抽出にも同じデコーダを使うと対応形式を揃えられます。
ポイントは次の3つです。
- ヘッドレスな短命mpvインスタンスを作る
-
vid=no/vo=null/ao=pcmでWAVを書き出す -
mpv_wait_eventを使うのでメインアクターでは実行しない
動画プレーヤーや動画解析アプリで、mkv / webm も含めて音声を取り出したい場合には扱いやすい方法でした。
関連記事:
この実装は、macOS向け動画プレーヤー Reel のVideo Notes機能で使っています。
