Advent Calendar

Julia で音声ファイルを加工してみた

Juliaを使って簡単な音声加工を行ってみました。
⚠記事の内容は学生個人の見解であり、所属する学科組織を代表するものではありません。

はじめに

こんにちは。物理学科3年の L という者です。最近 Julia を触り始めたのですがなかなかいい感じなので、今回は布教もかねて Julia で音声ファイルの簡単な加工をしてみます。Python でのプログラミングを知っていれば Julia のコードもなんとなく分かると思います。物理とはほとんど関係ないです。

Julia とは

The Julia Programming Language

筆者は Julia の全貌をまだ把握しきれていません。とりあえず、Julia はプログラミング言語の1つです。一番の特徴は Python と同じくらい気軽にプログラムが書ける1上で動作が速いことと言ってよいでしょう。並列処理などを活用してさらに高速化を図ることもできるようです。

ライブラリも充実していて、グラフのプロットや固有値計算だけでなく、群論・環論といった抽象代数学や、Webフレームワークを提供するライブラリも存在します。

また対話環境(REPL)も凝られています。Julia では全角文字もソースコード中で使うことができて、例えば円周率として"π"が用意されていたり、整数除算が"÷"だったりします2。しかし全角記号をわざわざ変換から出すのは面倒(そもそも Julia の開発者は日本語IMEとか持ってないはずなので)です。実は LaTeX コマンドの書き方が REPL 上でできます。"\pi" と打って Tab キーを押せば "π" になるし、"\div" と打って Tab キーを押せば "÷" になります。LaTeX に搭載されていないコマンドが Julia の REPL には搭載されていたり(\Alpha など)その逆もあったりして、遊んでいると無限に時間が溶けます。

あと Why We Created Julia和訳)がかっこいい。ここから生まれた言語が実用に耐える形で受肉していることを思うと、トレードオフって何だろうという気持ちになれます。

Julia を始めるのは簡単で、公式ページからインストーラをダウンロードしてインストール(Add Julia to PATH のチェックはオン)します。終わったらターミナルを開いて "julia" と打ち込めば対話環境(REPL)が始まります。Ctrl + D で抜けられます。

Julia で音声ファイルを加工してみよう

Julia のライブラリは広範で、音声ファイルの読み書きに関するものも存在しています。パッケージを追加するには、REPL 上で "]" を押してパッケージモードに移り、"add パッケージ名" とします。今回使うのは FileIO、LibSndFile、SampledSignals です。ライブラリを使う場合は "using パッケージ名" が必要です。

音声ファイルの読み込み

音声ファイルは Windows なら .mp3 などもありますが、今回は圧縮されておらずプログラムで扱いやすい .wav の読み書きをします。

snd = load("ooooo.wav")

とすれば snd に音声データが読み込まれます。具体的には

  • snd.data : 音の振幅
  • snd.samplerate : サンプリング周波数

という感じでアクセスします。サンプリング周波数というのは、アナログな音の振幅の情報をデジタル的に保存するときに、どういう間隔で音の振幅を保存するかという情報です。snd.data には音の振幅を表す-1以上1以下の実数が並んだデータ(配列)が保持されていて、1つ1つの振幅の時間間隔がサンプリング周波数の逆数になります。この振幅をいじることが音声ファイルの加工そのものになります。

また、音声ファイルにはモノラルステレオの2種類あります。モノラルは両方のスピーカーから出る音が同じで、ステレオは左右のスピーカーから違う音が出せる形式です。ステレオの場合は振幅の情報が右と左のそれぞれで必要です。snd.data には、モノラル形式のファイルを読み込んだ場合には1列の配列が、ステレオ形式のファイルを読み込んだ場合には2列の配列が入っています。

音声ファイルの書き込み

snd.data にいろいろ加工して得られた配列が result だとします。result を音声ファイルとして保存したい3場合は

buf = SampleBuf(result, snd.samplerate)
save("output.wav", buf)

のようにします4。出力する前に result の各データが-1以上1以下になっていることを確認しましょう5

エコーをかける : -)

エコーをかけることで閉鎖的な空間にいるような効果を出すことが出来ます。逆に言えば、閉鎖的な空間にいればエコーがかかります。音源の発した音が、耳に直接届くだけではなく壁に跳ね返って弱まってかつ遅れて届いたりもして、エコーになるわけです。

echo.png

これをプログラムで再現するには、ある振幅の音があったときに、その音に少し遅らせて強度も弱めた音を追加してやればよさそうです。

reduc = 0.3 # 何倍に弱めるか
delay = snd.samplerate * 0.1 # 何秒遅らせるか
repeat = 3 # 何回エコーを繰り返すか

for n in 1:length(snd.data)
  for i in 1:repeat
    m = Int(n - i * delay)
    if m >= 1 # Julia は 1-indexed
      result[n] += reduc^i * snd.data[m] # ←本体
    end
  end
end

"←本体" とコメントしてあるところが、数秒前の振幅(snd.data[m])を今の振幅(result[n])に足し合わせている部分です。reduc^i という部分は跳ね返った回数だけ音が弱まるということを再現しています(Julia の階乗は "^")6

加工のビフォーアフターを見せるためのサンプル音声は棒読みちゃんに出してもらいました。

加工前

加工後(エコー)

トレモロ・ビブラート

どちらも音を揺らすようなエフェクトです。

トレモロは n 番目の振幅に

$$ a(n) = 1 + d\sin\dfrac{2\pi rn}{f_\mathrm{s}}\quad\left\{\begin{array}{l}f_\mathrm{s} : \text{サンプリング周波数}\\d : \text{トレモロの深さ}\\r : \text{トレモロの振動数}\end{array}\right. $$

をかけるというものです。つまり振幅が振動します。

加工前

加工後(トレモロ)

ビブラートはエコーと同じく遅延を利用するエフェクトです。遅延させる時間を揺らすことで音を揺らします。具体的には、

$$ \tau(n) = d+d\sin\dfrac{2\pi rn}{f_\mathrm{s}}\quad\left\{\begin{array}{l}f_\mathrm{s} : \text{サンプリング周波数}\\d : \text{ビブラートの深さ}\\r : \text{ビブラートの振動数}\end{array}\right. $$

として result[n] には snd.data[n-τ(n)] の音を入れます。また、n-τ(n) は整数ではないことがほとんどなので、n-τ(n) を挟む2つの振幅の間を線形補間して n-τ(n) にあたる振幅であろう値を取り出します。

fs = snd.samplerate  # サンプリング周波数
depth = fs * 2e-3  # ビブラートの深さ(秒)
rate = 5  # ビブラートの振動数(Hz)

for n in 1:length(snd.data)
  τ = depth + depth * sin(2pi * rate * n / fs)
  t = n - τ
  m = floor(Int, t)  # n - τ より小さくて一番近い整数
  δ = t - m  # 小さくて一番近い整数との差
  if m >= 1 && m + 1 < length(snd.data)
    result[n] = δ * snd.data[m + 1] + (1 - δ) * snd.data[m]  # 線形補間
  end
end

線形補間の処理もあるので見にくいですが、ビブラートをかける処理です。

加工後(ビブラート)

聴き比べると、トレモロでは音の大きさが振動しているのに対して、ビブラートでは音の大きさはほとんど変わっていないのがなんとなく分かります。

おわりに

今回の内容は Julia でなければできないということは当然ありませんが、Julia はこんなこともできるよという紹介でした。

コーディングしていると、累乗を ^ で書けるとか、何もインポートしなくても数学の関数が使えるといった細かい嬉しさがボディブローのように効いてきます7。配列が1始まりなのも使っていれば慣れてきて、数式をコードに落とし込むときにそのまま書けるありがたみを感じられるようになることでしょう8

Julia に入門したい人は、ググれば入門記事がいろいろ出てくるので見ながら練習してみてはいかがでしょうか。

最後に今回のプログラムの全体を貼っておきます。

プログラム全体
using FileIO: load, save
using LibSndFile
using SampledSignals

function echo_sample(filename)
  snd = load(filename*".wav")
  fs = snd.samplerate
  reduc = 0.3
  delay = fs * 0.1
  repeat = 3

  result = copy(snd.data)
  result = float(result)
  for n in 1:length(snd.data[:, 1])
    for i in 1:repeat
      m = Int(n - i * delay)
      if m >= 1
        result[n, :] += reduc^i * snd.data[m, :]
      end
    end
  end
  result .*= 0.99997*maximum(result)

  buf = SampleBuf(result, fs)
  save(filename*"-echo.wav", buf)
end

function tremolo_sample(filename)
  snd = load(filename*".wav")
  fs = snd.samplerate
  depth = 0.5
  rate = 5

  result = copy(snd.data)
  result = float(result)
  for n in 1:length(snd.data[: ,1])
    a = 1 + depth * sin(2pi * rate * n / fs)
    result[n, :] = a * snd.data[n, :]
  end
  result .*= 0.99997*maximum(result)
  buf = SampleBuf(result, fs)
  save(filename*"-trem.wav", buf)
end

function vib_sample(filename)
  snd = load(filename*".wav")
  fs = snd.samplerate
  depth = fs * 2e-3
  rate = 5

  result = copy(snd.data)
  result = float(result)
  for n in 1:length(snd.data[: ,1])
    τ = depth + depth * sin(2pi * rate * n / fs)
    t = n - τ
    m = floor(Int, t)
    δ = t - m
    if m >= 1 && m + 1 < length(snd.data)
      result[n, :] = δ * snd.data[m + 1, :] + (1 - δ) * snd.data[m, :]
    end
  end

  buf = SampleBuf(result, fs)
  save(filename*"-vib.wav", buf)
end

println("\resonance/")
echo_sample("physlab")
vib_sample("larmor")
tremolo_sample("larmor")

このスクリプトファイルがあるフォルダに physlab.wav と larmor.wav を配置して "julia WaveEffect.jl" とターミナルに打つと実行できます。事前に LibSndFile, SampledSignals を Julia に追加するのを忘れずに。

この記事はPhysics Lab. 2022 Advent Calendar 2021 11日目として書かれました(日付をまたいでしまいました。ごめんなさい)。

参考文献

    注釈

  1. 青木 直史『C言語で始める音のプログラミングーサウンドエフェクトの信号処理ー』(オーム社、2018)

  2. 丸井 淳史『Juliaで音信号処理 (geidai.ac.jp)

脚注

  1. 動的型付け言語なので型を省略して書くことができます。ただ時には型を意識する必要もあります。Python でも Numba というライブラリを利用すれば Julia と同程度に速くなるという話があるらしいです。

  2. ちなみに Python の整数除算 "//" は Julia では分数を表す演算子になります。デフォルトで分数がサポートされているというのが筆者にはかなり刺さったポイントらしい(デフォルトで行列演算とか複素数がサポートされてるのも嬉しい)。ちなみについでに、Julia の XOR は "⊻" という文字が担っており、REPL 上で "\xor" と打って Tab キーを押せば出せます。あと、最近は VSCode で全角文字を打っていると「ρ と p は見分けがつきにくいですよ!」などと黄色く縁取って警告してくるようになったので settings.json に "editor​.unicodeHighlight​.ambiguousCharacters"​: false を書き加えて黙らせましょう。

  3. PortAudio というライブラリを使えば Julia プログラム内で音声を再生することもできます。

  4. こうして保存したものは、場合によっては Windonws 10 のデフォルトのメディアプレーヤーである Groove ミュージックだとファイルの形式がサポートされていないといったエラーが出ます。フリーソフトの VLC メディアプレーヤーからなら再生できます。

  5. [-1,1] をはみ出てもちゃんと出力はされますがはみ出たところは -1 か 1 に揃えられて潰れたような音になります。なお Groove ミュージックも VLC メディアプレーヤーも補正して潰れた音にならないように再生してくれるみたいなので個人で聞くだけならはみ出てもいいという説も。あと、load した1つ1つの振幅データは [-1,1] の範囲だけが許される特別な型に格納されているようで、result に snd.data をコピー(ディープコピーするには copy を使う)したものを入れるというのも1つの方法です(途中で [-1,1] を超えることも許されないので加工の上では不便だったりします)。

  6. この書き方はステレオ形式のファイルには通用しません。モノラル形式の場合は snd.data[n] には n=1 から n=length(snd.data) まで振幅のデータが入っています。一方ステレオ形式のファイルの場合は n=1 から n=length(snd.data)÷2 に入っているのは右の、n=length(snd.data)÷2+1 から n=length(snd.data) に入っているのは左の振幅データで、上のコードでは右の終わりの方の時間の音が左の始まりの方の時間の音にエコーを引き起こすというよくわからん境界条件になってしまっています。これを正すには "←本体" の [n] とか [m] を [n, :] とか [m, :] に書き換えてやれば大丈夫です。こうすると result[n, :] や snd.data[m, :] はその時刻における右と左の振幅が並んだ横ベクトルになります(横か縦かは行列との演算ができるかによって確かめられます)。その場合 reduc^i をかける部分はベクトルのスカラー倍と、+= の部分はベクトルの足し算と解釈されて実行されます(この辺りは numpy と同じ)。この書き方ならモノラル形式でも1列しかない横ベクトルと見なされて通ります(最初からこの書き方にしなかったのは記事を書きながら気付いたからです。Julia の行列の仕様っぽいですね、知見でした。裏取り大事)。ちなみに snd.data[n] の代わりに snd[n] としてもアクセスできます。

  7. 嬉しいときに「ボディブローのように」って言うのは間違っていませんか?と言いたい。

  8. 最近では分野によっては数式の方を0始まりに合わせているようですが。

作者紹介
L
幾何班員。MathJaxとNext.jsの相性の悪さについて小一時間は語れます。主観性。