時域的音高追蹤,是指直接使用 wav 檔的波形進行音高追蹤。這種方法通常會在音檔內容比較簡單的狀況下使用,例如單一樂器的演奏,或者單一人的清唱等等。本篇將由肉眼觀察波形開始,慢慢進階到半自動,乃至全自動的計算音高。部分內容在基本處理的篇章中已經介紹過,若你已經熟悉,可以自行跳過。

我們先產生一個特定頻率的正弦波,然後看看能否在假裝不知道它的頻率的情況下,只用波形來看出它的音高。以下範例會產生一個 440 Hz,長度兩秒鐘的正弦波,並且把波形圖畫出來;你可以進行局部放大,並觀察一或多個週期經過了幾個取樣點,並經由取樣頻率來推算出音訊頻率。比方說,取樣點 20,009 到 20,191(從 0 起算,下同)經過了 5 個週期,你也知道取樣頻率是 16 kHz,因此推得的音訊頻率是 16000 / ((20191 - 20009) / 5) = 439.56 Hz。雖然由於取樣等因素,而讓推算的結果跟真實的結果有些許誤差,但兩者已經相當接近。

import matplotlib.pyplot as plt
import numpy as np

DUR = 2
F = 440
FS = 16000

t = np.arange(DUR * FS) / FS
y = np.sin(2 * np.pi * F * t) * 0.8 * 32768

plt.plot(y, '.-')
plt.xlabel('Sample Index')
plt.ylabel('Raw Value')
plt.show()

如果要進行比較自動化的音高追蹤,也有許多方法,本篇先介紹一個簡單的方法,是利用自相關函數(autocorrelation function, ACF)來進行音高的自動化計算。此方法的概念是先將自己複製一份,然後不斷滑動,並將兩份之間重疊的範圍做向量內積。一開始完全重疊的時候,算出來的值會最大;而慢慢滑出去以後,內積值會漸漸變小,但是滑動距離過了半個週期,開始接近一個週期時,值又會慢慢變大,然後在一個週期的地方產生一個高峰,依此類推。由於前述步驟是針對一個 frame 來計算,因此將取樣頻率除以第一個高峰的位置,就是該 frame 的推測音高。

例如以下範例中,依然是產生了一個頻率為 440 Hz 的正弦波,但我們先框出其中一個 frame 來計算 ACF,可發現第一個高峰出現在索引值為 36 的位置,因此推測其音高為 16000 / 36 = 444.44 Hz。各位可以發現此處仍然有誤差存在,但如果仔細觀察 ACF 的計算結果,並腦補出一條曲線的話,會發現大約 36.3 的地方才是最高點,如果拿 36.3 去除 16000,計算出來就會是誤差小很多的 440.77 Hz,因此,一些比較進階的方法,會對 ACF 等方式計算出的結果進行曲線擬合,來取得誤差比較小的音高。

import matplotlib.pyplot as plt
import numpy as np

DUR = 2
F = 440
FS = 16000
FRAME_SIZE = 512
sample_start = 20000
sample_end = sample_start + FRAME_SIZE

t = np.arange(DUR * FS) / FS
y = np.sin(2 * np.pi * F * t)
y = y[sample_start:sample_end]
acf = np.array([np.sum(y[i:]*y[:FRAME_SIZE-i]) for i in range(FRAME_SIZE)])

plt.subplot(2, 1, 1)
plt.plot(y, '.-')
plt.subplot(2, 1, 2)
plt.plot(acf, '.-')
plt.show()

在上述範例中,第一高峰的位置是用目測來找尋,如果你想要自動計算的話,則不能直接取整段曲線的最大值,因為整段曲線的最大值一定會出現在開頭,而頭幾個點的位置代表過高而不合理的頻率,所以通常會將頭幾個點去掉後,再來找剩餘部分的最大值。

我們接著要來使用真人錄製的清唱聲音,來示範全自動化的 ACF 音高追蹤。以下使用的是 MIR-1K 資料集中的 geniusturtle_1_01.wav 音檔,並框出某一個特定的 frame 來進行音高追蹤:

import matplotlib.pyplot as plt
import numpy as np
import scipy.io.wavfile

WAV_PATH = 'MIR-1K/Wavfile/geniusturtle_1_01.wav'
FRAME_SIZE = 512
sample_start = 26000
sample_end = sample_start + FRAME_SIZE

fs, y = scipy.io.wavfile.read(WAV_PATH)
print(fs, y.dtype, y.shape)

y_vocal = y[:, 1]
y_vocal = y_vocal[sample_start:sample_end]
y_vocal = y_vocal / 32768
acf = np.array([np.sum(y_vocal[i:]*y_vocal[:FRAME_SIZE-i]) for i in range(FRAME_SIZE)])

max_idx = np.argmax(acf[32:]) + 32
freq = fs / max_idx
 
plt.subplot(2, 1, 1)
plt.plot(y_vocal, '.-')

plt.subplot(2, 1, 2)
plt.plot(acf, '.-')
plt.plot(max_idx, acf[max_idx], 'ro')
plt.text(max_idx+3, acf[max_idx], 'Freq: {:.4f} Hz'.format(freq))

plt.show()

在上述範例中:

對於追蹤所有音框的音高,你可以很簡單的加上一層迴圈。我們也順便將 MIR-1K 資料集中的音高標註,跟自動追蹤的結果做對照,只是由於資料集中的標註單位是「半音」,因此需要進行適當的轉換:

import matplotlib.pyplot as plt
import numpy as np
import scipy.io.wavfile
from mir_eval.melody import evaluate
from mir_eval.util import midi_to_hz

WAV_PATH = 'MIR-1K/Wavfile/geniusturtle_1_01.wav'
LAB_PATH = 'MIR-1K/PitchLabel/geniusturtle_1_01.pv'
FRAME_SIZE = 640
HOP_SIZE = 320

with open(LAB_PATH, 'r') as fin:
	pitch_gt = list(map(float, fin.read().splitlines()))

fs, y = scipy.io.wavfile.read(WAV_PATH)
print(fs, y.dtype, y.shape, len(pitch_gt))

y_vocal = y[:, 1]
y_vocal = y_vocal / np.max(np.abs(y_vocal))

frame_time = []
frequencies = []
pitches = []
for frame_start in range(0, y_vocal.size, HOP_SIZE):
	frame_end = frame_start + FRAME_SIZE
	if frame_end > y_vocal.size:
		break
	frame_time.append((frame_start + frame_end) / 2 / fs)
	acf = np.array([
		np.sum(y_vocal[frame_start+i:frame_end+1]*y_vocal[frame_start:frame_end-i+1]) \
			for i in range(int(FRAME_SIZE/2))
	])
	max_idx = np.argmax(acf[32:]) + 32
	freq = fs / max_idx
	pitch = 69 + 12 * np.log2(freq / 440)
	frequencies.append(freq)
	pitches.append(pitch)

plt.subplot(2, 1, 1)
plt.plot(np.arange(y_vocal.size) / fs, y_vocal)

plt.subplot(2, 1, 2)
plt.plot(frame_time, pitches, '.-', label='Calculated')
plt.plot(frame_time, pitch_gt, '.-', label='Groundtruth')
plt.legend()

freq_gt = [midi_to_hz(pitch) if pitch != 0 else 0 for pitch in pitch_gt]
scores = evaluate(frame_time, freq_gt, frame_time, np.array(frequencies))
print(scores)

plt.show()

在上述範例中:

前述的 ACF 只是時域音高追蹤的其中的一種方法,若有興趣知道其他方法,例如 NSDF、AMDF 或多種方法的綜合使用,或是以動態規劃(Dynamic Programming, DP)等方式來對結果後處理等等改進,都可以自行找資料研讀。