Pythonで再生速度変更(今度は音の高さを変えずに)

Python
この記事は約11分で読めます。

さて今回は、動画サイトやDVDデッキの機能としてよく見かける『音の高さを変えずに再生速度を変える』ことに挑戦してみましょう。

再生速度を変える(音の高さは変えない)

再生速度を変えると、波形はどうなるか

前回のような『テープやアナログレコードの回転数を変えるような』再生速度変更は、再サンプリングという作業は必要になりましたが、全体の波形はそのままで、単に時間方向に圧縮したり伸張したりという作業でした。たとえば速度を半分にするスロー再生ならば、

たとえばこの波形を、

このように時間方向(横軸)に引き延ばすことで実現できました。

しかしこれでは音の周波数(高さ)も半分(1オクターブ低い音)になってしまいます。

『音の高さを変えない』で再生速度変更する場合は、波形を時間方向に変形(周波数を変更)することができませんので、

この波形を、高さを変えずに速度を半分(持続時間を2倍)にするためには、

このように波形を補う必要があります。その分、処理はかなり複雑になりそうです。

アルゴリズム

音の高さを変化させずに再生速度を変化させる、とくに再生速度を下げる場合には、波形を補ってやる必要があることが判りました。ではどうやって補えば良いでしょうか?

やりかたはいろいろありますが、今回はもっとも簡単な『近くの波形をコピーして使う』という手段を考えます。

理想的には、以下のような処理を行います。

人間の声にしろ楽器の音にしろ、一定の音が持続している場合は、同じ形の波が繰り返された波形になっています。その波形の1波長分(繰り返し波形パターンの1つ分)を取り出してコピーし、元の波形の適切な部分に挿入することで全体の長さを伸ばすことができます。この方法ならきれいに連続した波形をいくらでも伸ばすことができます。

ただ現実的には、『ここからここまでが1波長』ということを検出するのが難しいので今回は見送りです。

比較的簡単な速度変更のアルゴリズム

もっと機械的に行える処理として、こちらのサイトで解説されている方法を試してみます。

…解説をリンク先に丸投げしていると、万が一移転などでリンク切れとなったときに困るのでこちらでも簡単に。

元の波形を一定の幅の『窓』で切り取り、それをコピーします。次に、『窓』を一定距離動かして、また元の波形を切り取り、先ほどコピーした波形の後に並べていきます。

『1回ごとに窓を動かす幅=窓の幅』とすれば、元と同じ波形が再現されます。下のアニメ-ションをご覧下さい(クリックして拡大するとアニメーションが始まります)。赤い破線が『窓』です。

『1回ごとに窓を動かす幅=窓の幅×2』とすれば、元の波形をとびとびに、全体の約半分コピーすることになります。これで再生速度が倍になります。

『1回ごとに窓を動かす幅=窓の幅×0.5』とすれば、元の波形を一部重複しながらコピーして並べ、全体はもとの2倍の長さになります。これで再生速度が1/2になります。

ただ、速度変更後の波形を観れば判るとおり、コピーとコピーの境界で線が途切れてしまいます。

実装

元データ

今回は、このような波形を実験に使います。

まずは何も考えずアルゴリズム通りに

import numpy
import wavfile

# 元音声の読み込み(16ビットモノラル限定)
f = wavfile.readfile("input.wav")
src = f["ldata"]
samplingfreq = int(f["samplingfreq"])
dt = 1 / samplingfreq

# いろいろ設定
windowsize=int(samplingfreq*0.05)   # 『窓』の幅
speed = 0.7                         # 再生速度(元の速度に対する比)
count = int( (len(src)-windowsize)/(windowsize*speed) )
dst = numpy.zeros(int(len(src)/speed), dtype="int16")

for i in range(0,count):
    src_s = int(i*windowsize*speed)
    dst_s = i*windowsize

    for i in range(windowsize):
        dst[dst_s+i] = src[src_s+i]
    
# 保存
wavfile.writefile(numpy.array([dst]), "output.wav", samplingfreq=f["samplingfreq"])

アルゴリズムの通りに実装しました。ごく簡単なプログラムなのでコードについての説明は不要でしょう。wavfileモジュールはwavファイルの読み書きのためのもので、こちらの記事の下の方にあります。

『窓』のサイズはいろいろ考えられますが、とりあえず50msにしてあります。先ほどの参考サイトでは50msを推奨していますが、これは人間の可聴範囲の最低周波数とされる20Hzを再現できるように、ということのようです。このアルゴリズムでは、『窓』の幅より波長が長い(周期が長い)周波数成分の波形を破壊してしまい、再現できません。『窓』のサイズが小さくなればなるほど、再現できる最低周波数は高くなってしまいます。

さて、では再生速度を0.7倍で試してみましょう。

たしかに音の高さは変わらず、持続時間は1/0.7≒1.4倍になっています(元が一定の音なので『ゆっくり』になっているかどうかはよく判りません)。が、パチパチという大きなノイズが気になります。

波形を観るとこのようになっています。並べていったコピーの境界付近で位相がずれたことでできた余分な山や谷がノイズの原因になっているようです。

クロスフェードでつなげてみる

import numpy
import wavfile

# 元音声の読み込み(16ビットモノラル、サンプリング周波数44.1kHz限定)
f = wavfile.readfile("input.wav")
src = f["ldata"]
samplingfreq = int(f["samplingfreq"])
dt = 1 / samplingfreq

# いろいろ設定
windowsize=int(samplingfreq*0.05)   # 『窓』の幅
xfaderate = 0.2                 # クロスフェードで重ねる部分の幅(窓の幅に対する比)
speed = 0.7                     # 再生速度(元の速度に対する比)
count = int( (len(src)-windowsize)/(windowsize*speed) )
dst = numpy.zeros(int(len(src)/speed), dtype="int16")

for i in range(0,count):
    src_s = int(i*windowsize*speed)
    dst_s = i*windowsize

    # クロスフェードで繋げる
    for i in range(int(windowsize*(1+xfaderate))):      # クロスフェードのため、窓の幅よりxfaderate分だけ余分にコピー
        try:
            if i<windowsize*xfaderate:
                dst[dst_s+i] = dst[dst_s+i]*(1-(i/(windowsize*xfaderate))) + src[src_s+i]*(i/(windowsize*xfaderate))    # クロスフェード
            else:
                dst[dst_s+i] = src[src_s+i]                                             # 他と重ならない部分はそのままコピー
        except:
            break
    
# 保存
wavfile.writefile(numpy.array([dst]), "output.wav", samplingfreq=f["samplingfreq"])

コピーした波形をただ並べるのではなく、隣同士をすこし重ねて、クロスフェード(前の波形をフェードアウト、後の波形をフェードインすることで滑らかに繋げる)してみました。

パチパチというノイズは軽減されましたが、まだ一定間隔で音が断続しているようになってしまっています。

波形をみると、たしかにコピーの境界付近で振幅が小さくなっています。これは位相のずれにより、前後の波形が打ち消し合ってしまっているようです。

位相を揃える

import numpy
import wavfile

# 二乗誤差平均を求める
def calcerror(a1, a2):
    error = 0
    for i in range(len(a1)):
        try:
            error += ((a1[i]-a2[i])**2)/len(a1)
        except:
            break
    return error

# 元音声の読み込み(16ビットモノラル)
f = wavfile.readfile("input.wav")
src = f["ldata"]
samplingfreq = int(f["samplingfreq"])
dt = 1 / samplingfreq

# いろいろ設定
windowsize=int(samplingfreq*0.05)   # 『窓』の幅
xfaderate = 0.2                 # クロスフェードで重ねる部分の幅(窓の幅に対する比)
speed = 0.7                     # 再生速度(元の速度に対する比)
count = int( (len(src)-windowsize)/(windowsize*speed) )

dst = numpy.zeros(int(len(src)/speed), dtype="int16")

for i in range(0,count):
    src_s = int(i*windowsize*speed)
    dst_s = i*windowsize

    # 2乗誤差平均がいちばん小さいところに繋げる
    if i>0:
        src_s0 = src_s
        min = 65535**2
        for j in range(src_s, int(src_s+windowsize*xfaderate), 1):
            error = calcerror(dst[dst_s:dst_s+int(windowsize*xfaderate)], src[j:j+int(windowsize*xfaderate)])
            if error<min:
                min=error
                src_s0 = j
        print(src_s, src_s0-src_s,min)
        src_s = src_s0

    # クロスフェードで繋げる
    for i in range(int(windowsize*(1+xfaderate))):      # クロスフェードのため、窓の幅よりxfaderate分だけ余分にコピー
        try:
            if i<windowsize*xfaderate:
                dst[dst_s+i] = dst[dst_s+i]*(1-(i/(windowsize*xfaderate))) + src[src_s+i]*(i/(windowsize*xfaderate))    # クロスフェード
            else:
                dst[dst_s+i] = src[src_s+i]                                             # 他と重ならない部分はそのままコピー
        except:
            break
    
# 保存
wavfile.writefile(numpy.array([dst]), "output.wav", samplingfreq=f["samplingfreq"])

次に、元の波形からコピーする位置を多少ずらして、もっとも上手く繋がる場所を探すようにしてみます。クロスフェードのために余分にコピーしてい部分との差がもっとも小さくなる位置を、クロスフェードと同じ幅の範囲で探してからコピーします。

今度はノイズが入っていません。波形を見ても、完全に一定の波が並んでいます。上の2つの例と同じ部分を図示しているので、両端付近に境界があるはずなのですが、どこが境界だか観ても判りません。

しかし、これで完成ではなかった…

これで再生速度変換のプログラムは完成!

かと思いきや、実はこのあと大きな問題が発生しました。続きは次回…。

コメント

タイトルとURLをコピーしました