ルギア君の戯言

雑多な記事。

回り道

ffmpeg をビルドするより ffmpeg で安全に変換できる道を通っていくことにしました。
その結果、mp3 → flacogg (後半は oggenc を使う) という道が見付かりました。
しかし、これではタグがコピーされません。


それでできたシェルスクリプトは、

#!/bin/sh

# settings
mpginfo=mpginfo
ffmpeg=ffmpeg
oggenc=oggenc
vorbiscomment=vorbiscomment
ogginfo=ogginfo

for src in `find ./* | grep "mp3$" | sed "s/\.[^\.]*$//g"`
do
  title=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*TIT2\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  if [ "$title=(empty)" ]
	then title=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*title\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  fi
  artist=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*TPE1\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  if [ "$artist=(empty)" ]
	then artist=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*artist\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  fi
  album=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*TALB\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  if [ "$album=(empty)" ]
	then album=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*album\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  fi
  year=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*TYER\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  if [ "$year=(empty)" ]
	then year=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*year\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  fi
  track=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*TRCK\s*:.*$"| sed "s/.*:\s//g" | nkf --utf8`
  if [ "$track=(empty)" ]
	then track=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*track\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  fi
  track=`echo $track | sed "s|/.*$||g"`
  genre=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*TCON\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  if [ "$genre=(empty)" ]
	then genre=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*genre\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  fi
  desc=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*COMM\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  if [ "$desc=(empty)" ]
	then desc=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*comment\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  fi
  composer=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*TCOM\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  if [ "$composer=(empty)" ]
	then composer=""
  fi
  isrc=`$mpginfo "$src.mp3" 2> /dev/null | grep -P "^\s*TSRC\s*:.*$" | sed "s/.*:\s//g" | nkf --utf8`
  $ffmpeg -i "$src.mp3" -f flac -acodec flac -ac 2 -ar 48000 -aq 10 - | \
  $oggenc -q 10 -o "$src.ogg" -
  $vorbiscomment -a "$src.ogg" -t "TITLE=$title" \
			        -t "ARTIST=$artist" \
			        -t "ALBUM=$album" \
			        -t "DATE=$year" \
			        -t "TRACKNUMBER=$track" \
			        -t "GENRE=$genre" \
			        -t "DESCRIPTION=$desc" \
			        -t "COMPOSER=$composer" \
			        -t "ISRC=$isrc"
  echo "$ogginfo \"$src.ogg\""
  $ogginfo "$src.ogg"
  echo ""
done

これで、変換に関しては問題なくできる。だが、落し穴が。mpginfo が出力したタイトルなどのタグ情報は UTF-8 でも Shift-JIS でも EUC-JP でもなく、UCS-2 (UTF-16)だった*1。しかも、UCS-2 の 2 バイトのうち、先行のバイトは潰されている。つまり、mpginfo が出力したデータをパイプなどで読むのではデータが欠落していることになる。当然 UTF-8 に変換することもできない。ただし、ASCII 文字は先行バイト(リトルエンディアンの場合)がすべて 0x00 であるため、潰されても ASCII 文字である。
つまり、シェルスクリプトでは不可能だということになる。
となれば、perlpythonruby かと言ったところだろう。
というわけで perl ライブラリか python ライブラリか ruby のライブラリで、TagLib が使える物を探す。

perl-MP3-Tag

これかぁ・・・やっぱり面倒臭い(笑)
そういうときにはやっぱり C で書くことにしてしまうのがルギア君である。
ちょっと可笑しいよね(笑)
mp3 や ogg のタグを読み書きするライブラリだが、TagLib というのを見付けた。ogg のタグの文字コードは本来は UTF-8 だが、このライブラリにかけると自動的に UCS-2 になる。つまり、そのままコピーするだけでよい。
で、最初は大したプログラムでも無いし、C で書こうと思ったのだが、TagLib はなんと C++ のライブラリだったため、C++ で書かざるを得ず・・・C++ だと精神が蝕まれそうなのに・・・(ぇ)
もちろんコピーするだけなら C++ で、std::string のような実装をされていたので

to = from;

で済む。しかし、今度は逆に Shift-JIS タグの MP3 ファイル達が問題になった。手元の MP3 のライブラリは UCS-2 タグのもの*2と、Shift-JIS タグのものが混在している。好機だから、Shift-JIS タグの MP3 は OGG にする際に UTF-8 にしたい(というか、UTF-8 でなければいけない)。
そのため、識別と変換が必要になった。まったく面倒な話である。ffmpeg が上手く働いてくれれば*3・・・
まずは Shift-JIS の識別から。
(注1)から、下 8 ビットを炙り出して、char 配列に 1 つないしは 2 つ並べ、それぞれが特定の範囲に入っていることを確認するだけでよいことがわかる。
特定の範囲というのは、正規表現で言うと、

/[\x00-\x7F\xA1-\xDF]|[\x81-\x9F\xE0-\xFC][\x40-\x7E\x80-\xFC]/

だが*4、これだけのために regexp のライブラリをリンクするのはナンセンスすぎる。もっとも僕の場合は regexp ライブラリの使い方を知らないというのもある。
まず 1 バイト目を見て、0x00 から 0x7F または 0xA1 から 0xDF の範囲にあれば 1 バイトで完結する Shift-JIS である(前半は ASCII、後半は半角カタカナ)。
もし範囲になくても、1 バイト目が 0x81 から 0x9F または 0xE0 から 0xFC の範囲にあって、2 バイト目が 0x40 から 0x7E または 0x80 から 0xFC の範囲にあれば 2 バイトの Shift-JIS である。
全ての文字がこの条件を満たすなら、Shift-JIS である。
なお、UCS-2 では 全てのビットパターンがあるため、実際には UCS-2 の文字列であっても、Shift-JIS と認識され兼ねないが、日本語と言う環境下では滅多に起きないようである。
プログラムにすると、こうだ。

bool ifsjis(TagLib::String& input) {
  bool only_ASCII = true;
  for(int i = 0; i < input.length(); i++) {
    unsigned short int tmp[2];
    tmp[0] = input[i];
    if(tmp[0] < 0x0080) {
      continue;	// no problem.
    } else if(tmp[0] > 0x00A0 && tmp[0] < 0x00E0) {
      only_ASCII = false;
      continue;
    } else {
      only_ASCII = false;
      i++;
      tmp[1] = input[i];
      if(((tmp[0] > 0x0080 && tmp[0] < 0x00A0) || (tmp[0] >= 0x00E0 && tmp[0] < 0x00FD)) &&
    	  ((tmp[1] > 0x003F && tmp[1] < 0x007F) || (tmp[1] >= 0x0080 && tmp[1] < 0x00FD))) {
    	continue;	// noproblem.
      } else {
    	return false;
      }
    }
  }
  if(only_ASCII)
    return false;
  else
    return true;
}

continue が散見されるが、実際には必要ない。ASCII 文字のみからなる文字列も Shift-JIS であるが、変換コストを考え、ASCII文字しかない場合は false を返すようにしてある。UCS-2 で日本語が占める領域は 0x0100 よりも大きいため、このような特殊な環境下では誤認識は起こり得ない。
さて、重要なのは変換のほうだ。
変換するには LC_CTYPE (LANG 環境変数)を巧みに操って変換する方法と nkf や iconv を呼び出す方法、iconv の実装を真似るの 3 種類がある。前者はなぜか無理だった*5。3番目は面倒なので、Linuxシステムコールの勉強も兼ねて、2番目を選んだ。
最初は Windows でもコンパイルできるようにシステムコールはできるだけ減らしたいと思ったが、この際なのでやむを得ない(ぁ)
さて、iconv を呼び出すとなれば、iconv の仕様をみる必要がある。

入力 出力 (デフォルト)
iconv stdin stdout

だからまず思い浮かぶのはリダイレクトだろうが、リダイレクトする場合はどうしてもファイルを生成しなければならないため、ディスクアクセスが生じる。(たぶん、/dev/stdin /dev/stdout では駄目だと思われる(試してないが))
じゃあ、パイプか。しかし、Linux のパイプは双方向ではないので、帰り道は別のルートを通ってこなければいけない。scim-prime がどうやっているのか*6なんとなく気になる(ぁ
とりあえずそんなことは置いておいて、行きは popen で送り、帰りは・・・というと、FIFO で戻ってこようか。
PATH_ICONV は iconv へのパスね。

FILE* p;
FILE* f;
mkfifo("./iconv-fifo", 0600); // ← FIFO の作成(エラー時は -1 (詳細は errno))
p = popen(PATH_ICONV " -f sjis -t utf8 > ./iconv-fifo", "w"); 
// ↑ popen は sh を通じて実行するのでリダイレクトができる
// ↓ 両端とも開いていないと書き出せないので読み込み側も開いておく
f = fopen("./iconv-fifo", "r");
for(int i = 0; i < tmp.length(); i++) {
  fputc((unsigned char) tmp[i] & 0xFF, p);
}
pclose(p); // ← EOF を送らないと iconv は変換を開始しないので、先に閉じて EOF を送る
while(1) {
  char c = fgetc(f);
  if(feof(f))
    break
  fputc(c, stdout);
}
fclose(f);
unlink("./iconv-fifo"); // ← 削除(終了時でよい)

見た感じ C だけど途中で宣言しているから C++ ね(ぁ)。FIFO の名前は何でも言いんだけど、FIFO っていうけど、その言葉ってマニュアルにしか出てこないよね(ぁ)。Dolphin でも nautilus でも ls でさえも パイプ(p)って言っているもんね。
ところで、見ていたら jconv (1字違い)というライブラリで文字コード変換ができるようではありませんか。これは、日本語だけっぽいな。まあ、用は達成できるから問題ない。


つづく

*1:Shift-JIS タグの MP3 も内部では UCS-2 で処理されている。全ての Shift-JIS のバイトに 0x00 をつけて UCS-2 にしている。そのため、Shift-JIS で出力され(るように見え)、nkf や iconv で utf8 に変換することができる。

*2:ID3v2 の仕様を確認したら UTF-8 ではなく UCS-2 だったそうです。

*3:実はこれでもタグはコピーされない。

*4:http://homepage1.nifty.com/nomenclator/perl/ShiftJIS-Regexp-j.html

*5:iconv は LC_CTYPE を巧みに操ることによって変換しているわけではないというのが重要なポイントである。ただし GNU による実装での話。

*6:http://taiyaki.org/prime/protocol.html