目次


洗練されたPerl: MP3とPerlで遊ぶ、第2回

PerlでMP3タグをさらに操作・推測する

Comments

この記事は2部構成のシリーズの2回目です。この記事を読む前に第1回の記事を読んでください。第1回ではautotag.plアプリケーションとその中で使われている各種のモジュールの原理を紹介しています。今回は前回終わったところからそのまま始めることにします。

メイン・ループを準備する

autotag.plのメイン・ループは曲を特定してタグ付けします。そのためには少し準備が必要です。最初にWebService::FreeDBモジュールを使ってFreeDB検索オブジェクトを作成します。

ユーザーがautotag.plのFREEDB_DEBUG設定を記憶しておく必要がないように、この検索オブジェクトはautotag.plの設定からDEBUG設定を継承します。ホストもautotag.plの設定で提供されます。

リスト1. WebService::FreeDBオブジェクトを作成する
my $cddb = WebService::FreeDB->new(DEBUG => $config->DEBUG(),
                                   HOST => $config->FREEDB_HOST);
die "Could not initialize the FreeDB service"
 unless defined $cddb;

次に、%discs%olddiscinfo%disc_countsといういくつかのハッシュを作成します。また@commonリストも作成します。こうした変数はどれもメイン・ループに役立つものです。FreeDBの各検索結果はそれぞれ独自のIDで識別されますが、このプログラムのずっと後まで保存しておくのはそのIDだけだということには注意してください。

私はユーザーから入力される-artist-albumといったコマンドライン検索スイッチ全て(スイッチを手動でリストする代わりに%freedb_searchesハッシュのキーを使って)をたどっています。個別のパラメーターの値を取得するにはAppConfigのget()メソッドを使います。注目するパラメーターは常に配列参照なので、私は自動的に参照を外しています。何も検索スイッチが付いていない場合には、ユーザーが対話形式で検索条件を付けられるように、相互動作モードに入ります。

リスト2. 検索スイッチはオンか?
my $search_count = 0;
foreach my $search (keys %freedb_searches)
{
 $search_count += scalar @{$config->get($search)};
}
print "Search count is $search_count\n"
 if $config->DEBUG();

大したことではないと思えるかも知れませんが%freedb_searchesハッシュを使って検索スイッチのリストを取得するとコードが短くなり、保守もしやすくなるのです。プログラムで定数が重複したり文字列の間違いが起きたりするのを避けるためには、常にこうしたことを考える必要があります。

検索カウントの知識の装備を持って対話型のクエリー・モードに入ります(ユーザーはこのモードに入りたいかを問われ、入りたくないと答えるとプログラムは静かに終了します)。対話型のモードではまずユーザーがguess_artist_and_track()という、ふさわしい名前の付いた機能を使って、演奏者と曲名をある程度推測します。この推測はautotag.plに与えられた全てのファイルに行われます。全MP3ファイルの中で同じ演奏者を繰り返し検出しても一つの推測しか生成しないようにサブ・ハッシュを使い、推測が%guessedハッシュに累積されます。次に各検索に対するこうした推測が適切なものかどうか、ユーザーに対して尋ねます。例えば私が演奏者検索を要求すると、演奏者名の推測が最初にユーザーに提示されるのです。

ですから私は当然の成り行きとして、検索に対して対話型のクエリーにたどり着きます。%freedb_searchesハッシュでの各検索に対して、ユーザーはさらに検索を追加します。単にEnterキーを押すとread_line()機能が空の文字列を返しますが、そうした入力はユーザーが続行を望んでいる印だと受け取られるのです。

リスト3. 対話型の検索クエリー
while (my $data =
 read_line("Add a search by $search or ENTER to go on: ", ''))
{
 last unless defined $data && length $data;
 push @{$config->get($search)}, $data;
}

ここでもAppConfigのget()メソッドを使って設定リストへの配列参照を取得し、ユーザーから入力されたデータをその配列にプッシュします。

初期検索

FreeDB検索は先に挙げた検索基準に基づいて対話的、またはコマンドライン・スイッチで行います。ここでも%freedb_searchesハッシュを使って検索のリストを作ります。検索ごとにユーザーが与える検索単語を規定し、与えられたその単語を-allスイッチに追加します。各「単語」には空白(Space)も含まれることには注意してください。autotag.plとFreeDBに関する限り、単語は単一の検索単位です。

リスト4. FreeDBを検索する
foreach my $search (keys %freedb_searches)
{
 # @keywords will contain all the keywords (e.g. -artist "Pink Floyd")
 my @keywords = @{$config->get($search)};
 # we join in the -all keywords for every search
 push @keywords, @{$config->get(SEARCH_ALL)};
 print "Asked for keywords @keywords, search $search\n"
  if $config->DEBUG();
 # remember the searches and keywords done
 push @{$freedb_searches{$search}->{keywords}}, @keywords;
 # do the search
 foreach my $keyword (@keywords)
 {
  print "Searching with keyword $keyword, search $search\n"
   if $config->DEBUG();
  my %found_discs = $cddb->getdiscs($keyword, [$search]);
  if ($config->OR())                    # any search with OR
  {
   push @common, keys %found_discs;
  }
  elsif (scalar @common)                # second or more search without OR
  {
   my @new = keys %found_discs;
   my $lc = List::Compare->new(\@common, \@new);
   @common = $lc->get_intersection();
  }
  else                                  # first search without OR
  {
   @common = keys %found_discs
  }
  foreach my $disc (keys %found_discs)
  {
   $discs{$disc} = $found_discs{$disc};
   $disc_counts{$disc}++;       # we'll use this to remove matches later
  }
 }                              # foreach @keywords
}                               # foreach keys %freedb_searches

FreeDBデータベースから結果が返ると、その結果は%found_disksハッシュに入れられます。このハッシュは各キーワードに対して生成されるので、前の結果は現れません。ユーザーが-orスイッチを指定した時には、私は単純にその結果を他方(下記訳注)に追加しています。それ以外の場合には@common配列を使って、前の結果と何が共通かを調べます(ANDモードは暗黙的です。つまりデフォルトとして、検索結果は要求されたキーワード全てに一致する必要があるのです)。List::Compareモジュールは2つのリストが重なる部分を作るのに使います。手動でそれを行うのもそれほど難しくはありませんが、テスト済みでずっと早い実装がある時にあえて手間と時間をかける意味は無いでしょう。

再度注意して欲しいのですが、全ての検索結果はFreeDBデータベースにあるアルバムに対応する単なる文字列IDです。ですからそれらをハッシュ・キーや検索する文字列のリストなどに使うことができるのです。

最後にフィルターをかけた結果を%discsハッシュに追加し、検出された各アルバムの%disc_countsエントリーを増加します。ディスク・カウントは後で使います。私は変数名に「disk」ではなく「disc」を使っていますが、これはFreeDBがそう使っているからです。

結果が全て揃ったら、@commonに無いものを削除します。-orを付けることで@common配列は全ての結果を含んでしまうことを思い出してください。

リスト5. @commonを使って不必要な結果を除去する;どれも必要ない場合には終了する
foreach my $disc (keys %discs)
{
 next if grep { $_ eq $disc} @common;
 print "Deleting search result $disc, it was not in all searches\n"
  if $config->DEBUG();
 delete $discs{$disc};
}
unless (scalar keys %discs)
{
 print "The search you requested returned no discs, sorry.  Exiting.\n";
 exit;
}

%discsのキーのループを抜けるのは単純で、grep()を使って@commonにディスクがあるかどうかを調べます。ハッシュを使ってこのループをさらに最適化することもできたのですが、正直言って、ユーザーが何十万というアルバムを検索するのでもない限り大した違いはないと思います。

%discsに何もアルバムが残っていなければこれで完了です。メッセージを表示し、しとやかに終了というわけです。

好きなディスクを選択する

これでアルバムのリストができました。ユーザーは自分の望むものだけがこのリストに入っているつもりになっています。実際にはこのリストは通常、ユーザーが思っているものよりも大きいので、ユーザーが本当に欲しいものだけを選べるようにします。こうすることで、見つかったアルバムが実際にユーザーの望むものと違っている場合には、ここで中止することができます。autotag.plの-accept_allスイッチを使うと、ユーザーは見つかった全てのアルバムに対して選択メニューを使わずに操作できます。途中の経過によらず、最終結果は@selecteddiscsリストの中にあることになります。

リスト6. ディスクを選択し、リストを出力する
my @selecteddiscs;
if ($config->ACCEPT_ALL())
{
 @selecteddiscs = keys %discs;
}
else
{
 print "Enter the albums of interest for files [@ARGV]\n";
 @selecteddiscs = $cddb->ask4discurls(\%discs);
}
unless (scalar @selecteddiscs)
{
 print "You selected no albums, exiting...\n";
 exit 0;
}
%olddiscinfo = %discs;                  # save the old data for ask2discurls
%discs = ();                            # clear the search results
# populate %discs with full search results
foreach my $disc (@selecteddiscs)
{
 my %discinfo = $cddb->getdiscinfo($disc);
 $discs{$disc} = \%discinfo;
}
if ($config->DUMP())
{
 print Dumper \%discs;
 exit 0;
}

WebService::FreeDBモジュールにはask4discurls()機能があって便利です。これが無かったら自分で書かなければなりませんでした。これはアルバムのリストを出力して、ユーザーに自分の好きなアルバムを選ばせるのです。

アルバムの最終リストは@selecteddiscsにあるので、%discsはそこにIDがあるアルバムのみを取得します。getdiscinfo()機能は曲番号とアルバム情報をアルバム・エントリーに入れます。これは動作の遅い機能で、私はわずかな数のアルバムしか残っていない時にしか使いません。

そしてようやくメイン・ループです

コマンドライン(@ARGVで)で与えられる各ファイルに対してID3タグを取得し、必要であれば作成します。何らかの理由でID3タグがないファイルは、その旨を伝えるメッセージを出力してスキップします。ここでID3タグがないファイルと、単に存在しないファイルとの間には違いがあります。例えば、MP3ファイルとして名前が付いたディレクトリ名や、十分なパーミッションが無いためアクセスできないファイルなどはID3タグが無いファイルの例です。

リスト7. ID3タグを取得する
foreach my $file (@ARGV)
{
 my $tag = get_tag($file, 1);
 unless (defined $tag)
 {
  if (-r $file && -f $file)
  {
   print "Could not get a tag from file $file, skipping";
  }
  else
  {
   print "Nonexistent file $file, skipping";
  }
  next;
 }
... the rest of this loop is explained later ...
}

%discs_of_interestハッシュは%discsのコピーです。ディスクの選択を狭めるために、文字列を近似(曖昧)一致させる面白いモジュールを使ってみました。例えばアルバム名を曖昧に(50%から90%の正確さで)一致させようとしてみたのですが、どの設定もうまく動作しませんでした。問題は、例えば「love」のような単語は一般的すぎ、「U2」のような単語では短すぎるのです。選択を狭めるためのうまいアルゴリズムがあるのかも知れないので、そうしたアルゴリズムが出てきた時に備えて%discs_of_interestハッシュを置いておきました。ただ、私の個人的な経験からは人に選ばせるのが一番です。人の頭なら0.01秒でオプションを選べるでしょう。問題をコンピューターに解かせようとすると、数百万年にも渡る進化よりも効率が悪いこともあるのです。

さてwhile(1)ループです。ユーザーはたいていの場合、一つを決めるまでに繰り返しいろいろ選ぶので、それを反映してエンドレス・ループになっています。可変制御でこのループを書くこともできたのですが、エンドレス・ループでnext()last()を使った方がより自然だと思います。

次のループで一つのアルバムを取得します。

リスト8. 一つのアルバムのみを選ぶ
my @chosen = ();
# do the following unless only one album is selected
if (1 == scalar keys %discs_of_interest)
{
 @chosen = (keys %discs_of_interest)[0];
}
else
{
 # get the ask4discurls special format back from %olddiscinfo
 my %ask4discurls_special_hash;
 foreach (keys %discs)
 {
  $ask4discurls_special_hash{$_} = $olddiscinfo{$_};
 }
 do
 {
  print_tag_info($file, $tag);
  print "Choose a single album or none (to skip file) from the current list\n";
  @chosen = $cddb->ask4discurls(\%ask4discurls_special_hash);
 } while (scalar @chosen > 1);
};
last if scalar @chosen == 0;
next if scalar @chosen != 1;
my $disc = $discs{$chosen[0]};

たった一つのアルバムしか存在しないのであれば、ただそれを取得します。それ以外の場合は、ask4discurls()機能を使って選びたいディスクのリストを取得します。思い出して頂けると思いますが、この質問以前にファイルタグの情報をprint_tag_info()で出力しているので、ユーザーにはファイルの情報が知らされています。ユーザーは元来忘れっぽいものなので、プログラマーがショートカットやお知らせを用意しておくとユーザーの助けになります。それにユーザーは完全でもないので、一つのアルバムを選べと言ったから一つだけ選ぶことを期待すべきではありません。GUIであればリストを挙げたボックスで選択規則を強制することもできますが、autotag.plの文字インターフェースでは入力の検証が必要です。ただし、実際には必ずしもそうとは限りません。ここで役に立つCPANモジュールもあるのです。ただ、autotag.plの規模やその対象範囲では文字モードのUIの枠組みを使うまでもないようです。

アルバムが何も選ばれていない場合にはスキップして次のファイルに進みます。

これで$discにはユーザーが調べている現在のファイルに特定なアルバムがあることになります。

リスト9. 曲番号がなかなか見つからない
my $track_number_guess = guess_track_number($file, $tag);
my $tracks = $disc->{trackinfo};
my $track_number;
do
{
 # ask the user for the track number, while trying to be helpful
 print_tag_info($file, $tag, "Old tag");
 $cddb->outstd($disc);
 $track_number =
  read_line(
   sprintf(
    'Choose a track number 1 - %d, 0 to quit, -1 to select another album: ',
    scalar @$tracks),
   $track_number_guess);
} while (not defined $track_number ||
  	     $track_number < -1 ||
	     $track_number > scalar @$tracks);
# cycle to the album selection again if the user wants to select another album
next if $track_number == -1;

これもエンドレス・ループです。ユーザーが適切な曲番号を選ぶまでは次に進めません。曲番号はautotag.plのAhabへのMoby Dickのようなものです。これが見つからないと、この作業が無意味になります。ユーザーに何をして欲しいかを知らせるためにタグ情報を再度出力し、次にWebService::FreeDBoutstd()機能でディスク情報を出力します。

ユーザーが入力するデフォルトの曲番号は、ファイル名や以前存在した曲から推測することもできます。これは通常単なる助言にすぎませんが、ユーザーがそれを受け入れる場合には単にEnterキーを押しさえすればよいのです。これはサービスというものです。

曲番号が見つかり、それが妥当なものであればMP3ファイルのタグ付けは完了です。

リスト10. タグ付け完了
# if the user selected a track...
if ($track_number > 0)
{
 my $new_tag = make_tag_from_freedb($disc, $track_number);
 print_tag_info($file, $new_tag, "New tag info") if defined $new_tag;
 # do this if the new tag was created, DRYRUN was not specified, and the
 # user says YES
 if ($new_tag
     && !$config->DRYRUN()
     && read_yes_no(
"Apply new tag (you'll get a chance to modify it)?", 1))
 {
  my $modify_tags = read_yes_no("Modify tag elements?", 0);
  # copy each new element (but don't overwrite valid old ones)
  foreach my $element (keys %$new_tag)
  {
   my $old_tag_element = $tag->{$element} || '';
   if ($modify_tags)
   {
    # the user can press Up Arrow to get the old tag element
    $term->addhistory($old_tag_element);
    $new_tag->{$element} =
     read_line("New value of $element (was '$old_tag_element'): ",
	 $new_tag->{$element});
    # put the artist and album $new_tag changes back in $disc so the
    # next file can also use them
    if (exists $info2freedb{$element})
    {
     $disc->{$info2freedb{$element}} = $new_tag->{$element};
    }
   }
   $tag->{$element} = $new_tag->{$element};
  }
  set_tag ($file, $tag);
 }				# if apply_new_tag...
}				# if $track_number > 0
last;

最初に、ここではエンドレスのwhile(1)ループであることを思い出してください。最後にあるlast()は、ここまで来てしまった場合にはループを出る必要があることを意味しています。

make_tag_from_freedb()機能を使ってFreeDB tagタグからID3 tagタグを作ることから始めます。これは直接的なマッピングではないので機能の中に隠れてしまっています。

新しいタグと、ユーザーからの最終的な「yes」が与えられると、ファイルのタグ付けに進みます。ユーザーはここで各タグ要素を修正することができます。ここでの各選択は入力オブジェクト履歴に保存されます(詳細はTerm::Readlineの資料を読んでください)。こうすることでユーザーは上向き矢印(↑)を押して以前の入力を選ぶことができ、タイプ入力し直す必要がありません。そして最後に、修正した情報でそのまま保持する必要のあるものは%$discハッシュに保存し直します。こうすることで、複数ファイルをタグ付けする時にはユーザーが本当に楽になるのです。ですからユーザーがアルバムの演奏者を修正すると、次のファイルでは修正されたその名前が今度はデフォルトの名前になります。

When all is said and done, the tag is set withset_tag().

使い方

私は自分のMP3のコレクションをカタログ化してタグ付けし直すのにautotag.plを1ヶ月以上使ってみました(それまではID3 1.1タグしかありませんでした)。私としてはautotag.plを使うのは簡単だと思っているのですが、それは私が不完全なところに慣れきってしまい、不完全さが気にならなくなったせいかも知れません。読者の皆さんはautotag.pl、特にそのコマンドライン・スイッチと文字列UIを改善するにはどうすべきか、遠慮無く助言してください。

「良い」タグが付いたMP3がいくつかあり、それを共通フォーマットで名前を付け直したいだけであれば-roオプションを使ってください。

曲番号が間違ったMP3がいくつかある場合には、-gオプションを使って素早く曲番号を取得することができます。曲番号は単に推測なので、各推測をあとで確認することができます。

間違ったコメントの付いたMP3がいくつかある場合には、-scオプションを使ってコメントを削除します。明示的に「COMM=」を使って一括タグ付けすることもできますが、-scの方がより便利です。

一つのアルバムから作ったMP3がいくつかあり、設定したいタグが分かっている場合には-mオプションを使って、好きな形でタグのエントリーを設定します。-helpオプションは、サポートするタグ・エントリー(ID3でのフレーム)のリストを出力します。

まとめ

autotag.plを書くのは苦しいものでしたが、楽しくもありました。私は曖昧文字列一致やFreeDB検索を使い、またID3バージョン1と2、それに文字モードでのユーザーとの相互動作もいろいろ使ってみました。それがすべて、一月に渡って十分にテストした一つのアプリケーションにまとまったのです。

autotag.plを書く上で一番難しかったのは、作業に最適なモジュールを選択することでした。2部構成のこのシリーズ第1回の記事で、autotag.plから排除した全モジュールについて説明しましたが、私は資料を読んだだけでそうしたわけではありません。資料は必ずしも正しいとは限らず、ときにはこうあって欲しいと言うことが書いてあるだけということもあります。各モジュールをテストする一番の方法は、コードを書いて、どれくらいうまく動くかを見てみることです。ですから私がautotag.plで選択したものはどれも実際に苦労してテストした結果なのです。ID3関連のPerl作業をするには、こうした選択だけでも有益なことが分かって頂けるのではないかと思います。

今後ともずっとダウンロードできるように、autotag.plはそのまま置いておきます。改善は続けますので、追加したい機能や改善要望などがありましたら是非助言してください。また指摘されたバグは喜んで修正しますので、バグに気が付いたらお知らせ下さい。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=228120
ArticleTitle=洗練されたPerl: MP3とPerlで遊ぶ、第2回
publish-date=01272004