Chienomi

音声ファイルの変換と転送

開発::noddy

※ 本記事は非本質部分においてセンシティブな要素に関する記述がある。

このスクリプトは私が実際使っているもので、音声ファイルを元形式によらずなるべく良い形でSDカードに転送することを目的としている。

実際の音声ファイルはDLSiteで購入した音声作品のものであり、これは次のようなルールを形成する

  • 再帰的サブディレクトリのいずれかがその一作品を形成する
  • 作品ディレクトリ以下には同じ内容が複数のファイルフォーマットが収録されている可能性がある
  • ファイルフォーマットはWAV, FLAC, AIFF, AAC, MP3あたり
  • 作品ディレクトリがどのレベルにあるかは固定ではないため機械的予測は難しい
  • 同じ内容で複数のファイルフォーマットからなる場合、それがどのようなファイルパスであるかは作品による
  • WAVでも特殊なフォーマットになっていることが多く、古いバージョンのWAVのHi-Resになっていたりと扱いに問題があることがある
  • ほとんどの場合、zipやrarからWindows上で展開するものであるため、ファイル名にWindowsファイルシステム上の問題はない (ある場合は手動で修正できるレベルである)

これに対して転送ファイルフォーマットは

  • PCM/FLACソースについてはFLAC
  • それ以外は AAC > その他の圧縮フォーマット > MP3

とする。

変換スクリプト

#!/bin/zsh

typeset sourcedir="$1"; shift
typeset -g DESTINATION_DIR="$1"; shift

if [[ -z $DESTINATION_DIR ]]
then
  print "NO DESTINATION."
  exit 1
fi

ls -R $sourcedir >| ~/tmp/filelist || { print "FAILED TO BUILD LIST"; exit 1 }

withconv() {
  typeset sourcedir="$1"
  typeset destdir
  if [[ ${sourcedir:h} != "." ]]
  then
    destdir="$DESTINATION_DIR/${sourcedir:h}"
  else
    destdir="$DESTINATION_DIR"
  fi
  if [[ ! -e $destdir ]]
  then
    mkdir -pv $destdir
  fi
  (
    (
      cd $sourcedir
      find . \( -name '*.wav' -or -name '*.aif' -or -name '*.aiff' \) -printf '%h\n' | sort -u | while read
      do
        print "LET $REPLY"
        (
          cd $REPLY
          for j in *.(wav|aif|aiff)
          do
            ffmpeg -i $j ${j:r}.flac
          done
        )
      done
    ) && rsync -rv -m --ignore-existing --include "*/" --include "*.flac" --include "*.jpg" --include "*.png" --exclude "*" "${sourcedir%/}" "$destdir"/ && rm -v $sourcedir/**/*.flac
  )


  exit
}

noconv() {
  typeset ext="$1"
  typeset sourcefile="$2"
  typeset destdir
  if [[ ${sourcefile:h} != "." ]]
  then
    destdir="$DESTINATION_DIR/${sourcefile:h}"
  else
    destdir="$DESTINATION_DIR"
  fi
  if [[ ! -e $destdir ]]
  then
    mkdir -pv $destdir
  fi
  rsync -rv -m --ignore-existing --include "*/" --include "*.$ext" --include "*.jpg" --include "*.png" --exclude "*" "${sourcefile%/}" "$destdir"/  
  exit
}

grep -q '.wav$' ~/tmp/filelist && { withconv $sourcedir }
grep -q '.aif$' ~/tmp/filelist && { withconv $sourcedir }
grep -q '.aiff$' ~/tmp/filelist && { withconv $sourcedir }
grep -q '.flac$' ~/tmp/filelist && { noconv flac $sourcedir }
grep -q '.opus$' ~/tmp/filelist && { noconv opus $sourcedir }
grep -q '.ogg$' ~/tmp/filelist && { noconv ogg $sourcedir }
grep -q '.aac$' ~/tmp/filelist && { noconv aac $sourcedir }
grep -q '.m4a$' ~/tmp/filelist && { noconv m4a $sourcedir }
grep -q '.mp3$' ~/tmp/filelist && { noconv mp3 $sourcedir }

print "NO MEDIA FOUND"
exit 1

解説

関数定義と呼び出し

メディアのファイルフォーマットに従って「変換して移動する」「変換せずにそのままコピーする」の2通りに分かれる。

FLACを含む圧縮フォーマットは変換の必要がない。一方、非圧縮PCMはFLACへの変換が必要だ。

もちろん、その詳細は対象拡張子が異なれば少し異なってくる。だが、処理としては2種類を用意し、わずかな抽象化によって処理可能だ。

対象ファイルフォーマットを発見するため、ファイルのリストを構築する。

ls -R $sourcedir >| ~/tmp/filelist || { print "FAILED TO BUILD LIST"; exit 1 }

潜在的にはこの場合ディレクトリ名が問題になるかもしれないが、現実的には一般のひとはディレクトリ名にドットをつける習慣はないから大丈夫だろう。

これによってプロジェクトディレクトリ以下のファイルリストがテキストファイルになる。 ここから拡張子を検索するのはgrepで簡単に行える。判定し呼び出す部分のコードは非常に簡単だろう。 command && { command } というのはまるでPerlのようなコードだが、この場合このほうが可読性が良いと判断して使っている。また、右辺リストは単一のsimple commandであるため、別にブレースは必要ない。つけているのは、開発中に一時、listである必要があるコードになっていたからだ。

PCMの変換

withconvはPCM音声をFLACに変換するスクリプトである。

ハイレゾPCMで、なおかつ長時間である音声作品のPCMは非常にサイズが大きいし、また粗な音声でもあるためFLACにすると結構サイズが小さくなる(0.55くらいになる)。

もともとはflacでエンコードし、失敗したらffmpegでエンコードする、という方法をとっていた。 これは、flac --bestffmpeg -c:a flac -compression_level 12よりも少し小さくなる一方、特殊な(正しくない)フォーマットが多い音声作品のPCMがflacでは失敗することがあるためだが、失敗する割合が多すぎるため、現在は最初からffmpegを用いるようになっている。

find-printf '%h\n'で見つかったファイルのディレクトリを行単位で出力できる。 当然ながら重複エントリができるため、sort -uで「対象ファイルのあるディレクトリ」をリストしている。 これはかなり広く使えるテクニックだ。

このように出力したエントリを扱うのは、やはりreadが良い。 前提として最初に

cd $sourcedir

しているので、この時点で「作品ディレクトリのルート」にいる。 これはどういうことかというと、Opus1/wav/SEon/01.wavという構成だったとして、Opus1に移動しているから、ファイルはwav/SEon/01.wavとして見つかり、findで出力されるのはwav/SEonであり、ffmpegで出力されるファイルはwav/SEon/01.flacである。

だから、

rsync -rv --include '*/' --include '*.flac' --exclude '*' ./ $DESTINATION

とした場合、作品内のディレクトリ構造を保ったままFALCファイルを転送することができる。

ただし注意点として、$DESTINATIONは「作品ディレクトリ名」まで含んでいる必要があり、グローバルなOUTDIRでは不十分である。

destdir="$DESTINATION_DIR/$sourcedir"

としているのはそのためで、$sourcedirが作品ディレクトリになる。

これはネストに対応している。$sourcedir$1だが、noconvに渡されている$1はコマンドラインの$1である。 つまりはコマンドライン引数であり、例えばDESTINATION_DIR=$HOME/outであり、voice-transfar.zsh _上海飯店/Hinata1のようにコマンドが起動されたならば、_上海飯店/Hinata1/**/*.flac$HOME/out/_上海飯店/Hinata1/**/*.flacに転送されることを意味する。rsyncするときの.$sourcedirなわけで、この場合Hinata1ディレクトリにいる状態だ。だから、rsync -rv ./ $HOME/out/_上海飯店/Hinata1/で構造を保ったままコピーされるわけだが、$DESTINATION_DIR$HOME/outで、$sourcedir_上海飯店/Hinata1なわけだから、rsync -rv ./ $DESTINATION_DIR/$sourcedir/で満たすことができる。 そして、これをまとめたものが$destdirになるわけだ。

rsyncのコマンドは特定拡張子のファイルのみ構造を保って出力する場合によく使う書き方だ。 rsyncだとネストされたディレクトリ構造などを保った形で転送してくれるので便利。

その上でrmしているが、もしかしたら気づいたかもしれない。 この処理は「PCMがあり、なおかつFLACもある」場合にFLACファイルを消してしまう、というバグがある。

今のところそういう形になっている音声作品を見たことがないので、とりあえず問題はないと思っている。

FAT向けのファイル名変換が入ってないので、処理は結構シンプルだ。 FAT向けファイル名変換の話はRipCD Evoの記事を参考にしてほしい。

無変換コピー

無変換の場合は、変換や削除がなくなってrsyncでコピーするだけなので、変換部分が理解できたなら読むのは簡単だろう。

違いとして、転送すべきフォーマットを明示しており、"*.$ext"の形で限定的に転送している。 これは、同じ内容でmp3とaacがある、みたいな作品は割とあったりするからだが、「メイン部分はPCMとMP3、おまけ部分はMP3だけ」みたいな収録の仕方をしているものがあり、これには対応できない。

一括変換

ほとんどの場合、そんなにいっぱい音声作品は買わないから1作品ずつコマンドラインで明示すれば良いのだけど、一括で転送したい場合は次のようなスクリプトを使っている。

#!/bin/zsh
setopt EXTENDED_GLOB
setopt BARE_GLOB_QUAL
setopt NULL_GLOB

if [[ -n "$1" ]]
then
  export DESTINATION_DIR="$1"
else
  print "NO DESTINATION DIR." >&2
  exit 1
fi

for i in (^(_*))(/) _*/(^(_*))(/) _*/_*/*(/)
do
  if [[ -e $DESTINATION_DIR/$i ]]
  then
    print "$i already exists. skpping."
    continue
  else
    print "PROCESSING $i" >>| proclist.txt
  fi
  ./voice-transflac.zsh $i $DESTINATION_DIR
done

前述の通り「作品ディレクトリがどのレベルにあるかは固定ではないため機械的予測は難しい」のであるが、であれば人間が予め整備してあげれば良い。 DLSiteの音声作品はzipまたは自己解凍rarで提供されるため、ルートにディレクトリが切っているアーカイブであれば、そのディレクトリが「作品ルート」になる。そうでない場合は、ディレクトリを作って、その中に展開すれば良い。

これがフラットに行われれば*だけで表現できる。しかし、実際はサークルやシリーズで分類したいこともある。

そこで、作品, _サークル/作品, _サークル/_シリーズ/作品というディレクトリ構成を取るものとした。 しかし、その前提だけではその全てが揃っていないと動作しない、ということになるので、NULLGLOBを追加することで一気にグロブで指定できるようにしている。 また、ゴミが入ってしまわないようにするため、BARE_GLOB_QUALを使い、(/)でディレクトリに限定している。

おまけ

変換・転送に失敗してFLACが生成できず、カバーアートだけが転送されてしまうということがある。

全処理スクリプトはディレクトリが存在すれば処理しないので、再試行がしづらかったため、次のようなコードを書いた。

#!/bin/zsh
setopt EXTENDED_GLOB
setopt NULL_GLOB

cd $1

for i in (^(_*))(/) _*/(^(_*))(/) _*/_*/*(/)
do
  if ! ls -R $i | grep -q -e 'flac$' -e 'mp3$' -e 'aac$' -e 'opus$'
  then
    print $i is empty.
  fi
done

現在のコードではそのようなことはそもそも起きないはずなので、必要なコードではないだろう。