Chienomi

プレイリストの「逆変換」をするスクリプト

Live With Linux::script

以前デジタルミュージックライブラリをCDにするで話題にしたが、私は古いクルマを買ったので、クルマで音楽を聴くためにCDに変換する、という作業が発生している。

アルバムをCD化していることもあるが、やはりプレイリストのほうがよく聴く。で、プレイリストの変換は

integer c=0; sed '/^#/ d' playlist.m3u | while read; do ffmpeg -nostdin -i "$REPLY" -ac 2 -ar 44100 ~/album/$((c++))-"${${REPLY:t}:r}.wav"; done

のようにしていたわけだが、そもそも私はプレイリストはポータブルオーディオでも聴けるように変換された側(つまりAACになっている方)で作っていて、それをソースとして変換すると当然ながらlossyなデータをwavにして焼くことになる。

私のポータブルライブラリはfdkaacで320kbpsで作ったものなので、実際の音としてはそれほど気になるようなものではないが、それでも気持ち的な問題としては少しひっかかりがある。

そこで今回、AAC向けのプレイリストをFLAC向けに変換し、そこからFLACの音源をwavに変換して焼く方針を考えた。

ただ、これが単純変換はできない。

というのもまず、ポータブルライブラリはFAT32/exFAT上に転送することが想定されているため、ファイル名の変換が行われているというのがひとつ。

また、ファイル名に問題があった場合のリネームも、ポータブルライブラリ上で発見されたものはポータブルライブラリで修正してしまったりしており、ファイル名に齟齬がある場合がある。

また、私の場合、CDのほかにDLM(ダウンロードミュージック)やHiResMusicというフォルダを切っており、DLMに関してはもともとmp3になっているものもあるため、常にflacにパスを変換すれば有効なわけでもない。

また、そもそもFLACが入っている方はデータアーカイブでもあるため、ファイル配置自体が異なり、同じファイルを見つけられる保証もない。

こうした問題に対応するためにRipcdreverse-playlistというツールを追加した。 今回の対応はyt-dlpでチャンネル動画をプレイリストに再編するで取った方法から着想を得た。

Overview

工程は2段階に分かれる。

まずはライブラリを作成するという作業。 これは、そのままパスを扱うだけだとしんどいため、変換した情報を含む形にしたデータベースが欲しいからだ。 そして次に、ライブラリを参照してマッチングを行って出力するという作業。

この2工程のためにスクリプトも2つある。

結論としてどのようにしたかというと、データベースは次のようになっている。

{
  ルート名 => {
    圧縮アーティスト名 => {
      圧縮パス => 実パス
    }
  }
}

この「圧縮」とは一体なんなのか、という話だが、

  • 拡張子を取り除き
  • 小文字に統一して
  • 記号類とスペースを除去

という処理をしたものになっている。 こうすると、DOSファイルシステム向けの改変が行われていても同一の文字列を得ることができ、なおかつ修正がなされても差が発生しづらい。

具体的にはこんなメソッドを定義し

class String
  def delsym
    self.downcase.unicode_normalize().delete("\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F  ")
  end
end

こんな感じで呼んでいる。

sq = fpath.sub(/\.[^\/]+$/, "").delsym

NFKC正規化を行っているのは、基本的には全角の記号と半角の記号の間での変換が発生することを想定したもの。

また、アーティスト名の所在が、私のライブラリだと2通りある。 というのも、$ARTIST/$ALBUM/$TRACKになっているものと、$ARTIST/$TRACKになっているものがあるためだ。

基本的にこれはルート直下のカテゴリによって異なるため、アルバムがないカテゴリを設定できるようにした。

マッチングの概念

このマッチングは、全ライブラリ(数千〜数万程度を想定)に対して全てString::Similarityを使ったスコアづけをしていると途方もなく重くなる、ということがまず念頭にある。

そのため、可能な限り絞り込みたいし、できれば問い合わせなしで解決したい。

カテゴリに関してはそんなに数のあるものではなく、かつ自力で作るものなので、この一致はシンボリックリンクを使って簡単にできる。 だから、カテゴリの完全一致は前提として要求し、1段階目の絞り込みを行うことにした。

前述の「圧縮」は非常に強力で、手で名前を修正する作業を経ていない限り、一致させることができる。 だが、一致しなかった場合にカテゴリ内の全曲を対象に選ばせるのはなかなかきつい。

そこで、より一致させやすい「アーティストに対するマッチング」を先に行うことにした。 もしアーティストを固定することができれば、同一アーティストの曲はせいぜい300曲程度。 私が最も多くの曲を持っているのはT-SQUAREだが、アーティスト名が「T-SQUARE」であるものに関しては284曲となっている。 この程度なら計算量としては全然イケる。

また、アーティスト名なら何を探してて見つからないのかわかっていれば、最悪コピペして手動入力もありえるので、手動入力も可能にした。

アーティストまで絞りこめたら、アーティストフォルダ配下で圧縮パスが一致するものを探す。 圧縮パスが一致するものがない場合(アーティストがmissingだった場合は必ず発生する)、そのアーティストフォルダ配下の圧縮パスに対するString::Similarityによるスコア付けと選択を行う。 この手順はほぼアーティストに対するものと同じで、同じような処理を2段階経て実パスへ到達する。

このデータベースは2つの圧縮パスを経ていおり、圧縮パス同士の比較を行うが、最終的なハッシュオブジェクトの値が実際のパスになっているので、結果実パスを得ることができるから非可逆的な変換も問題にならない。

実施可能な環境の整備

そもそもアーカイブ側では全く違うヒエラルキーになっているため、単純にポータブルライブラリとの対照ができない。

そのため、この作業はアーカイブライブラリにアクセスされる対照的なミュージックライブラリを構成する必要がある。 これは、カテゴリフォルダがアーカイブライブラリのディレクトリに対するシンボリックリンクになっているリンクファームを作れば解決できる。音楽を再生する上でも、そのように再編したほうが聴きやすいし、アーカイブ側はデータの性質に基づくヒエラルキー(例えばusr, pubなど)になっているから、ミュージックライブラリを作りたい場合はそのようにする前提にしている。

が、問題がひとつ。Rubyのfindライブラリはシンボリックリンクを解決しないのである。 find(1)を使ってもいいが、ちょっとスッキリしない。

ドキュメントには特に書かれていないが、実はFind.findは探索先パスが/で終わっている場合、シンボリックリンクでも解決するようになっている。

そこで生まれたのがこのトリック。

Find.find(*Dir.children(".").select {|i| File.directory? i}.map {|i| i + "/"}) do |fpath|

また、ディレクトリでないものに/をつけたものを渡すとその時点で例外発生なので、予めディレクトリでないものは除外している。

なお私の環境だと全てがアーカイブ’データマスター)に対するリンクというわけではない。 例えばHiResMusicに関してはそもそも変換していない状態で保持されているため、ポータブルライブラリへのリンクで問題なかったりする。

一部ミュージックストアのもの(例えばAmazon Music Store)はまた別のカテゴリとして分けられており、なおかつこれらはダウンロード時点でmp3だったりするため、これらもポータブルライブラリへのリンクで良い。

Ojの採用

Gemをあんまり好まない私はいつもはMarshalを使うのだが、今回はOjを採用した。

JSONで出力したところでヒューマンリーダブルになっているわけではないので、開くのさえ困難だが、一応は読めるということと、なにより少しでも速くしたかったからだ。

非常に低速になるのではないかと考えたが、数千曲程度のライブラリの私の環境ではそうでもなかったので、結果的にはMarshalでよかったと思う。

ところがどっこい行方不明FLAC

これで解決するかに思われたが、実際にプレイリスト変換をかけてみると、期待する候補が全く出てこない(場合によってはアーティストすらない)ものがちらほら見つかった。

調べてみると、アルバムディレクトリやカバーアートは存在しているが、FLACファイルが全くない。 どうやらアーカイブへの転送前に誤ってFLACファイルを消してしまったようだ。

急遽、スクリプトを書いてそのようなアルバムを検出することにした。

#!/bin/ruby

Dir.glob("CD_FLAC/*/*").each do |album|
  children = Dir.children(album).map {|i| File.extname i }
  unless children.include?(".flac")
    STDERR.puts "#{album} not include any FLAC."
  end
end

結果、22ほどのアルバムが音源を持っていないことがわかったので、これらは再度取り込み直す必要がありそうだ。

「簡単なツール」とも言い切れない

制作したツールは単純なコピペ要素を含む、比較的短いスクリプトが2つである。 一見するとちょっとしたことで書くような簡単なツールにも思える。

ただ、実は今回はdaily hackとしてはやや重めだった。

最大のポイントは単純変換できない、ということで、どうやれば意図した値を得られるかを「ちゃんと考える必要がある」ということで、時間をとって取り組む、あるいは頭に留めておいてアイディアを練るといった時間が必要で、そのようなパッと解決策が出てこないものというのは最終的な出力が簡単なものであれ、実際には簡単なものとは言い難い。

今回の場合

  • 変換対象は存在するファイルパスになるはずである = 結果として得られる値をファイルインデックスから限定すれば良い
  • 期待される変換で目的の値が得られない場合、文字列の近似によって推定が可能である
  • 関係ないものが重複することはなく、また小変更が小さい差異になるように変換し、その変換結果をキー、目的の値のバリューとしてハッシュで引けば簡単
  • 目的の値を機械的に算出できない場合、近似値を元に推定するが、推定をそのまま適用するよりもユーザーが選択するようにしてもこの場合大きな損失にならず、確実である
  • ユーザーが選択するならなおのこと実行時間が長すぎることは許容できず、ちゃんと計算量が少なくなるようにすべき。たとえそこで手作業が発生しても、対話的操作で待たされるよりマシ
  • 期待される値が存在しないエラーケースもあり、その場合ユーザーに委ねるべき

という考えをまとめることができたので形にできたというもので、ややこしい問題に良い解決策を出すというのは、結局のところプログラムとしては良い設計が求められるということであり、ちゃんとしたプログラムとそうは変わらないものになる。

こうした問題の整理と、愚直ではない最適なアプローチの模索ができるようになるというのは、初級から中級に向かうひとつの壁であるように思う。

本記事はタイトルも含め、あまり検索にひっかかる要素がないので人気記事にはなりづらいが、プログラミングが日常感覚として馴染んでいない初級者の助けになればということで、考え方を中心にして解説した。