🤔

iTunes内のALACファイル→FLAC変換をPowerShellで並列実行できるようにする

2023/11/09に公開

前回

https://zenn.dev/plesio/articles/5209f0432c0ccf

の失敗した点を改善していくお話。

背景・動機

前回のスクリプトでは、シングルスレッドとして稼働していたこともあり、FFmpegの処理がボトルネックになってしまった。
そのため、PowerShell側で並列処理を与えるようにすることで速度の改善を行いたい。

また、WAVの変換を混ぜたことでメタデータがないFLACができあがるミスもやってしまったので、併せて対処したい。

今回のゴール

  1. 前回のPowerShellのスクリプトに並列処理を盛り込む。
  2. WAVを変換する場合にFLACタグにメタデータを書き込めるようにする。

タイトル

環境

前回と同じ。

  • Windows 11 / Desktop
    • ディスク容量は十分に大きなものを用意しておくこと
  • FFmpeg 6
  • PowerShell V7.3.8 or Later.
    • 今回V7以降に入っている並列処理の機能を使うため、Windows内蔵版では動かないと思います。

調査

PowerShellで並列処理ってどうやって動かすの?

$maxParallelJobs = 4
echo $maxParallelJobs "parallel runs."

$Message = "Output:"

1..100 | ForEach-Object -Parallel {
    "$using:Message $_"
    Start-Sleep 1
} -ThrottleLimit $maxParallelJobs

こういうスクリプトで動く模様。
実際に走らせると、同時に4つに制限が掛かった状態になっていることがわかる。

CPUの物理コアの最大値ってどうやって知るの?

# CPUコア数を取得。
$cpuCores = (Get-CimInstance -ClassName Win32_Processor).NumberOfCores

Intel12世代以降では、Pコア、Eコアの種類が存在するが、この処理ではコア数だけを取り出してくるため、そのあたりを区別しない。注意。
また、コア数をフルで使おうとすると、システム側で何か処理が起きたときに割り振るリソースがなくなって宜しくないので、「物理コア数 - 1」くらいにとどめておくのが良いかもしれない。

一例としてCore i9-12900Kは、8コア16スレッドのPコア+8コア8スレッドのEコア=計16コア24スレッドを実現したCPUとなるのだが、そのあたりの実行最大値を決める計算をPowerShell上でさせるのは地味にしんどそう。
なので、タスクマネージャーとかから直接自身のマシンのコア数見て、手で最適な最大値に書き換えても良いと思う。

一応、今回のサンプルでは、プログラムらしく、スクリプト上からマシンの値を参照するようにはしておく。

これらを合体すると

# CPUコア数を取得。
$cpuCores = (Get-CimInstance -ClassName Win32_Processor).NumberOfCores
# 並列処理の上限を設定(コア数-1)
$maxParallelJobs = $cpuCores - 1

echo $maxParallelJobs "parallel runs."

$Message = "Output:"

1..100 | ForEach-Object -Parallel {
    "$using:Message $_"
    Start-Sleep 1
} -ThrottleLimit $maxParallelJobs

FLACのタグってどうやって編集するの

FFmpeg の引数にタグを編集するオプションがあるので、変換時に一緒に引き渡すのがよさそう。

# 前回のスクリプト
$ffmpegCommand = "ffmpeg -i `"$($file.FullName)`" -acodec flac -vcodec copy `"$dstFilePath`""

# タグを混ぜるオプションを入れた場合(一例)
$ffmpegCommand = "ffmpeg -i `"$($file.FullName)`" -acodec flac -vcodec copy -metadata album="Album"  `"$dstFilePath`""

これを wav のパターンのelseifを用意して、そこで実行してあげれば問題なさそう。

作業

前回のスクリプトを書き直す

先ほどのマルチスレッドの処理とWavの分岐を付け足したコードが以下。

convert_v2.ps1
# アーティスト名のリスト
$allowedArtists = @(
"@Plesio_'s Mastery Puppetz",
"BOaT"
)

# 元のフォルダと格納先のフォルダを指定
$srcDir = "E:\MUSIC\iTunes Media\Music"
$dstDir = "E:\TO_XPERIA_FLAC_TEST"

# ソースディレクトリ内のファイルを再帰的に取得
$files = Get-ChildItem -Path $srcDir -File -Recurse

# CPUコア数を取得。
$cpuCores = (Get-CimInstance -ClassName Win32_Processor).NumberOfCores
# 並列処理の上限を設定(コア数-1)
$maxParallelJobs = $cpuCores - 1

$d = Get-Date
Write-Output $d | Out-File -FilePath .\result.log

$filteredFiles = @()  # 結果を格納するための配列を初期化
# 
foreach ($artist in $allowedArtists){
    # アーティストのフォルダパスを組み立てる
    $artistFolder = Join-Path $srcDir $artist
    if (Test-Path $artistFolder -PathType Container) {
        $files = Get-ChildItem -Path $artistFolder -File -Recurse
        foreach ($fileTmp in $files){
            $filteredFiles += $fileTmp
        }
    }
}

Write-Host "$maxParallelJobs parallel runs."
Write-Output "$maxParallelJobs parallel runs." | Out-File -Append -FilePath .\result.log
Write-Output "${filteredFiles.Count} files target." | Out-File -Append -FilePath .\result.log

# ファイルごとに処理
$job = $filteredFiles | ForEach-Object -Parallel {
    $file = $_

    # JOBで分離するためループ内部で再定義する。
    $srcDir = "E:\MUSIC\iTunes Media\Music"
    $dstDir = "E:\TO_XPERIA_FLAC_TEST"

    # Title
    $title = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
    $trackNum = 1

    if ($file.Name -match '^(\d{2}\s)(.*)') {
        $trackNum = [int]$matches[1]
        $title = $matches[2]
    } else {
        # Nothing.
    }
    # タイトルの前の余分なスペースをトリム
    $title = $title.Trim()

    # ファイルの拡張子を取得
    $extension = $file.Extension.ToLower()

    # ファイルの親ディレクトリを取得
    $fileParentDir = [System.IO.Path]::GetDirectoryName($file.FullName)

    $album = (Split-Path -Path $fileParentDir -Leaf)

    # アルバムの親ディレクトリを取得
    $albumParentDir = [System.IO.Path]::GetDirectoryName($fileParentDir)

    # アルバムの親ディレクトリからアーティスト名を抽出
    $artist = (Split-Path -Path $albumParentDir -Leaf)

    $rtn = ""
    if ($extension -eq ".m4a") {
        # 拡張子が.m4aまたは.wavの場合、変換処理を実行
        $dstFilePath = $file.FullName.Replace($srcDir, $dstDir) -replace [regex]::Escape($extension), ".flac"
        $dstDirPath = [System.IO.Path]::GetDirectoryName($dstFilePath)

        # 出力ディレクトリが存在しない場合、作成
        if (-not (Test-Path -Path $dstDirPath)) {
            New-Item -Path $dstDirPath -ItemType Directory
        }

        $startTime = Get-Date
        # ffmpegコマンドで変換:強制上書きを有効にした。
        $ffmpegCommand = "ffmpeg -y -i `"$($file.FullName)`" -acodec flac -vcodec copy `"$dstFilePath`" " 
        Invoke-Expression $ffmpegCommand 
        
        $endTime = Get-Date
        $executionTime = $endTime - $startTime

        #$rtn = "FullName=" + $file.FullName + ", srcDir=" + $srcDir +  ", dstDir=" +  $dstDir + " , dstFilePath="+ ${dstFilePath}+ "\n"
        $rtn = "END: $endTime, exec_time: $executionTime - $artist > $album > $title"
    } elseif ( $extension -eq ".wav") {
        # 拡張子が.m4aまたは.wavの場合、変換処理を実行
        $dstFilePath = $file.FullName.Replace($srcDir, $dstDir) -replace [regex]::Escape($extension), ".flac"
        $dstDirPath = [System.IO.Path]::GetDirectoryName($dstFilePath)

        # 出力ディレクトリが存在しない場合、作成
        if (-not (Test-Path -Path $dstDirPath)) {
            New-Item -Path $dstDirPath -ItemType Directory
        }
   
        $startTime = Get-Date
        # ffmpegコマンドで変換:強制上書きを有効にした。
        $ffmpegCommand = "ffmpeg -y -i `"$($file.FullName)`" -acodec flac -vcodec copy -metadata album=`"$album`" -metadata title=`"$title`" -metadata artist=`"$artist`" `"$dstFilePath`" "
        Invoke-Expression $ffmpegCommand  

        $endTime = Get-Date
        $executionTime = $endTime - $startTime

        #$rtn = "FullName=" + $file.FullName + ", srcDir=" + $srcDir +  ", dstDir=" +  $dstDir + " , dstFilePath="+ ${dstFilePath}+ "\n"
        $rtn = "END: $endTime, exec_time: $executionTime - $artist > $album > $title" 
    } else {
        # それ以外の場合、単にコピー
        $dstFilePath = $file.FullName.Replace($srcDir, $dstDir)
        $dstDirPath = [System.IO.Path]::GetDirectoryName($dstFilePath)

        # 出力ディレクトリが存在しない場合、作成
        if (-not (Test-Path -Path $dstDirPath)) {
            New-Item -Path $dstDirPath -ItemType Directory
        }

        # 強制上書きを有効にした。
        Copy-Item -Force -Path $file.FullName -Destination $dstFilePath

        $endTime = Get-Date

        #$rtn = "FullName=" + $file.FullName + ", srcDir=" + $srcDir +  ", dstDir=" +  $dstDir + " , dstFilePath="+ ${dstFilePath}+ "\n"
        $rtn = "END: $endTime, exec_time: no(Copy-Only) - $artist > $album > $title"
    }
    $rtn
} -ThrottleLimit $maxParallelJobs -AsJob

$job | Receive-Job -Wait >> result.log

Write-Host "処理が完了しました。"

よく見ると、ディレクトリの作成のIF文は切り出せるとおもうからもっと短くできるね。面倒だからそのままにしとくけど。

実行テストも兼ねていろいろ触った結果、こうなった。
やはり、単純な合体では動作しないものだ。

手頃ないくつかのアーティストに絞って試験

目的は「ちゃんとFFmpegが並列実行されている?」という疑問を解消すること。
仕事で開発をしていると、「おまえ、同時に1処理しか動かないUIスレッド様か!!??」みたいなことがある。

今回はさすがにないと思うが、CPUリソースの割り当てが頭悪くて1個のCPUに待機列を生成する可能性が0ではない...

スパイクがかかっている箇所が ALAC->FLAC変換が数曲発生した箇所となる。一応..
数を絞りすぎて、わかりにくいので、アーティスト数を増やして、100曲くらい流せるようにして再検証。

良い感じだ。成功っぽい。

じゃあ本番実行だ!おやすみ... あれ?

機は熟した。あとは実行して寝るだけだ。
やり直しとなったの変換作業だが効率がマシになるはず。

これから300GBくらいぶん回して深夜中に終わってほしい、おやすみ。
あ、"""モニターは切って、ログオン画面にしておこう"""

...

おはよう。さすがに終わったかな・・・ってあれ?
(全然進んでない!!!)

なんと、PowerShell君、ログオン画面にまで戻されると処理が止まる仕様になっていた・・・

うわめんどくせ
正しく動作させるにはもうちょっと考えないといけないようだ。

自宅のマシンで自分以外触ることもないので、ひとまず、一時的に自動ロックの設定を完全にOFFにして、夜間バッチとして実行し、終わったら設定を戻すことで解決することにした。

所感

PowerShell で並列処理できるけど、動かせるようになるまで相当しんどい。
結局、良い感じの実装に最後まで落とし込むことができなかったが、とりあえず目的を達成できたのでヨシ

Discussion