跳轉到

編輯與效果

kudio.effects 把音訊編輯器對波形做的事寫成函式:淡入淡出、濾波、剪下與接合、裁切、音量,以及把一段錄音變成好幾段訓練資料的資料擴增。每個函式都接收一個 NumPy 陣列,回傳一個新的陣列:不會寫進你傳入的陣列,也不會把同一個陣列直接交回來,所以對結果做就地修改是安全的。

通道劣化(telephone、dropouts 和 G.711 壓擴器)也在同一個套件裡,另外有自己的一頁:房間與通道。

要做的事 用
避免片段在開頭或結尾出現喀聲 fade
去掉低頻隆隆聲、嗡嗡聲,或某個頻帶以外的所有東西 highpass、lowpass、bandpass、bandstop
去掉固定的偏移 remove_dc
剪掉一段,或補進一段 splice
去掉錄音室錄音或暫態聲音前後的靜音 trim_silence、split_on_silence
在有雜訊底的錄音裡找出語音 vad,在語音活動與音高
依標籤檔標的位置剪開錄音 load_labels
設定峰值 normalize、normalize_db
設定聽起來的響度 normalize_lufs,在檢查錄音
從一段片段得到更多訓練資料 pitch_shift、time_stretch、add_noise_snr、reverb、spec_augment
這一頁用到的檔案

take.wav 是六秒、RMS 0.01 的白雜訊,當作房間的環境音,裡面有兩段有聲語句:1.0 到 2.0 秒,以及 3.5 到 4.8 秒。retake.wav 是要補進去的一段 0.3 秒語句,stereo.wav 是同一段錄音的雙聲道版本,take.txt 則是標出這兩段語句的 Audacity 標籤軌。

import numpy as np
import kudio

sr = 16000

def voiced(seconds, f0):
    t = np.arange(int(seconds * sr)) / sr
    return sum(np.sin(2 * np.pi * f0 * k * t) / k for k in range(1, 12)) / 6

y = 0.01 * np.random.default_rng(0).standard_normal(6 * sr)    # the room
y[1 * sr:2 * sr] += voiced(1.0, 120)
y[int(3.5 * sr):int(4.8 * sr)] += voiced(1.3, 150)
kudio.save_wave("take.wav", y.astype(np.float32), sr)
kudio.save_wave("retake.wav", (0.5 * voiced(0.3, 130)).astype(np.float32), sr)
kudio.save_wave("stereo.wav", np.stack([y, 0.5 * y], axis=1).astype(np.float32), sr)
kudio.save_labels("take.txt", [kudio.Label(0.93, 2.07, "speech"),     # the spans
                               kudio.Label(3.43, 4.87, "speech")])    # vad finds

聲道與取樣率

凡是以秒為單位的函式,都用 sr 接收取樣率。淡入淡出、reverse、remove_dc、crossfade 和 splice 接受單聲道的 (n,) 片段,也接受 (frames, channels) 的片段,並對每個聲道做同樣的事。normalize、normalize_db、gain 和 random_gain 也一樣,所有聲道共用一個增益;如果用迴圈對每個聲道分別呼叫 normalize,每個聲道會各自被拉到自己的峰值,聲道之間的平衡就變了。

濾波器、pitch_shift、time_stretch 和 reverb 也接受 (frames, channels) 陣列,沿著 frames 軸處理,每個聲道各算各的。靜音相關的函式則在同樣的樣本位置切開每個聲道,而且只要有任何一個聲道有聲音,那個音框就算有聲音,所以立體聲錄音的各聲道不會錯開:

import kudio

y, sr = kudio.file_load("stereo.wav", mono=False)        # (frames, 2)
y = kudio.highpass(y, sr)                                # each channel
clip, (start, end) = kudio.trim_silence(y)               # one cut for both

add_noise_snr 只混單聲道訊號,其他的一律拒絕。如果對每個聲道分別呼叫,又用同一段雜訊和同一個種子,每個聲道會加進一模一樣的雜訊;如果各聲道的雜訊應該不同,請給每個聲道各自的雜訊。

splice 和 crossfade 只用 sr 把 duration 換算成樣本數,不會檢查各段的取樣率是否相同,所以請把每一段都用同一個取樣率載入。resample 對立體聲陣列也是沿著 frames 軸處理;取樣率的其他細節在音訊檔案。

淡入淡出

import kudio

y, sr = kudio.file_load("take.wav")
y = kudio.fade(y, sr, fade_in_s=0.01, fade_out_s=0.01)

從較長的錄音裡剪出來的片段,開頭和結尾都落在波形中間,等於一個階躍,而階躍聽起來就是喀聲。兩端預設各 10 ms,長度足以消掉喀聲,又短到聽不出是淡入淡出。fade_in 和 fade_out 各只處理一端。

shape= 曲線 適用情況
'linear'(預設) 振幅上的直線斜坡 防喀聲的淡化,短到曲線形狀無關緊要
'cosine' 升餘弦,兩端平坦 聽得出來的淡化,這樣開始和結束都不會太突兀
'exponential' 從 −60 dB 起在分貝上呈直線,再重新縮放,讓兩端剛好落在 0 和 1 緩慢的淡出:在 dB 上均勻,聽起來才均勻

淡化比片段還長時會引發 FeatureError;淡入和淡出重疊時也一樣,不會把中間那段同時乘上兩條斜坡。

直流偏移

remove_dc 會減掉每個聲道的平均值。固定的偏移會吃掉動態餘裕(headroom),每個剪接點都會發出一聲悶響。它還可能讓 trim_silence 什麼都裁不掉(見下方)。會漂移的偏移其實是非常低的頻率,而不是偏移,remove_dc 只能去掉它的平均值,剩下的交給高通濾波器:

import numpy as np
import kudio

sr = 16000
t = np.arange(4 * sr) / sr
y = 0.3 * np.sin(2 * np.pi * 200 * t) + 0.025 * t     # drifts from 0 to 0.1

for fixed in (kudio.remove_dc(y), kudio.highpass(y, sr, cutoff=20)):
    print(f"{fixed[:sr].mean():+.4f} {fixed[-sr:].mean():+.4f}")
# -0.0375 +0.0375      remove_dc: mean of the first and last second
# +0.0006 +0.0003      highpass

濾波器

import kudio

y, sr = kudio.file_load("take.wav")
y = kudio.highpass(y, sr, cutoff=80)       # rumble, handling noise
y = kudio.bandstop(y, sr, 45, 55)          # 50 Hz hum; (55, 65) for 60 Hz
y = kudio.lowpass(y, sr, cutoff=7000)      # hiss

四個都是 Butterworth 濾波器,除非你傳 order=,否則都是四階。

函式 預設 用來處理
highpass 去掉 80 Hz 以下 低頻隆隆聲、手持雜訊、緩慢的漂移
lowpass 去掉 8000 Hz 以上 嘶聲,以及以 16 kHz 訓練的模型頻帶以外的一切
bandpass 保留 300–3400 Hz 電話頻帶
bandstop 去掉 45–55 Hz 50 Hz 的市電嗡嗡聲

lowpass 的截止頻率在奈奎斯特頻率以上時

8000 Hz 的預設值正好是 16 kHz 音訊的奈奎斯特頻率,對 8 和 11.025 kHz 的音訊則在奈奎斯特頻率之上。那上面沒有東西可以去掉,所以 lowpass 會把片段原封不動地交回,但交回的是一份複本。其他濾波器的邊界必須嚴格落在 0 和奈奎斯特頻率之間,頻帶邊界前後寫反時會被拒絕。

預設為零相位

濾波器會先正向跑過片段,再反向跑一次(scipy 的 sosfiltfilt)。反向那一次抵銷了正向造成的相位偏移,所以沒有任何東西在時間上移動,濾波後的複本和原始訊號逐樣本對齊。凡是要和原始訊號逐樣本比較的東西都需要這一點,例如和輸入分開濾波的訓練目標。

跑兩次也會把振幅響應平方。斜率相當於兩倍階數的濾波器;在截止頻率處,訊號會低 6 dB,而不是一般講 Butterworth 時常引用的 3 dB:

import numpy as np
import kudio

sr = 16000
tone = np.sin(2 * np.pi * 1000 * np.arange(2 * sr) / sr)     # at the cutoff
middle = slice(sr // 2, 3 * sr // 2)                         # away from the ends

for zero_phase in (True, False):
    out = kudio.lowpass(tone, sr, cutoff=1000, zero_phase=zero_phase)
    gain_db = 20 * np.log10(np.std(out[middle]) / np.std(tone[middle]))
    print(f"zero_phase={zero_phase}: {gain_db:.2f} dB")
# zero_phase=True: -6.02 dB
# zero_phase=False: -3.01 dB

zero_phase=False 是因果濾波器,只跑一次,就像即時運作的裝置那樣濾波。它對每個頻率的延遲都不一樣:預設的高通會讓 100 Hz 延遲 4.8 ms,1 kHz 延遲 0.03 ms。要模擬之後會即時執行的處理時,就用它。

很短的片段也能濾波:sosfiltfilt 在兩端加的填補會縮小到放得下,只有單一個樣本會被拒絕。

kudio.enhance 裡的 wavelet_low_pass_filter 是降噪器,只是剛好呈低通,不能拿來代替這些濾波器;降噪器在降噪。

剪下與接合

從錄音裡剪掉一段,再用 np.concatenate 把兩邊直接接起來,波形會在接縫處跳一下。splice 則是把每一段的最後 5 ms 和下一段的前 5 ms 交疊混合。

刪掉一段後用兩種方式接回:np.concatenate 留下一個樣本的跳動,頻譜圖上有一條寬頻條紋;splice 沒有跳動,條紋弱了 44 dB 刪掉一段後用兩種方式接回:np.concatenate 留下一個樣本的跳動,頻譜圖上有一條寬頻條紋;splice 沒有跳動,條紋弱了 44 dB

圖中從一個 150 Hz、諧波到 3 kHz 的諧波音裡刪掉 6.5 個週期,所以兩邊接上時錯開了半個週期。直接接起來時,波形在兩個樣本之間跳了 0.54,將近諧波音其他任何地方最大跳動的九倍,頻譜圖上這聲喀聲是一條一路延伸到 8 kHz 的條紋。在 4 kHz 以上,諧波音本身什麼都沒有,條紋卻達到相對峰值 −33 dB。用 splice 接的接縫處,這個頻帶一直在−77 dB 以下。交叉淡化並沒有掩蓋兩邊錯開的事實:在那 5 ms 內兩邊會部分抵銷,造成的是準位下陷,而不是喀聲。

import kudio

y, sr = kudio.file_load("take.wav")
retake, _ = kudio.file_load("retake.wav", sr=sr)    # at the take's rate
a, b = int(1.20 * sr), int(1.45 * sr)               # a cough, say

fixed = kudio.splice([y[:a], y[b:]], sr)                 # cut it out
patched = kudio.splice([y[:a], retake, y[b:]], sr)       # or replace it

print(len(y) - (b - a) - len(fixed))           # 80, at 16 kHz

結果會比各段加起來短,每個接縫短 duration:除非你另外指定,否則是 DEFAULT_CROSSFADE,5 ms,在 16 kHz 是 80 個樣本。重疊的部分由兩段共用,不是額外插進去的。接縫之後的所有東西,都會比單純串接時提早這麼多,所以跟音訊保持同步的標籤或參考訊號也得跟著移。

空的段落會被略過,所以在最開頭或最結尾剪掉時,不會留下一邊什麼都沒有的接縫;交叉淡化比某一段還長時,會縮短到放得進那一段。

線性或等功率

crossfade 用一個接縫接起兩段片段,預設是 shape='equal_power',而 splice 預設是 'linear'。splice 是給同一段錄音裡的兩個時間點用的,crossfade 則是給兩段不同的錄音。在接縫正中間,線性交叉淡化給兩邊的權重各是 0.5,等功率則各是 0.71,所以:

接縫兩邊是 線性 等功率
同一個訊號延續下去 準位不變 接縫中間最多大聲 3 dB
兩個不相關的訊號 接縫中間小 3 dB 準位不變

這 3 dB 實際會出現多少,要看重疊的兩端怎麼對上。把一個振幅 0.5 的正弦波切成兩半,再用預設的 5 ms 接回去:440 Hz 時,等功率的峰值是 0.567;400 Hz 時是 0.695,因為重疊的 80 個樣本剛好是兩個週期,相位一致。線性兩次都維持在 0.500。

依靜音裁切與分段

trim_silence 切掉頭尾的靜音,回傳片段和它保留的樣本範圍。split_on_silence 在每個靜音空檔切開,回傳切出來的各段,但不附上位置。

兩者都只看準位。一個音框的 RMS 比最大聲的音框低 top_db(預設 30)或更多時,就算靜音。音框長 frame_length=2048 個樣本,位移 hop_length=512,單位是樣本而不是秒:在 16 kHz 是 128 ms 和 32 ms。邊界落在位移的格點上,而且往外偏,所以裁切後的片段兩邊都會留一點靜音:

import numpy as np
import kudio

sr = 16000
tone = 0.5 * np.sin(2 * np.pi * 440 * np.arange(sr // 2) / sr)
y = np.concatenate([np.zeros(sr // 2), tone, np.zeros(sr // 2)]).astype(np.float32)

for clip in (y, y + 0.02, kudio.remove_dc(y + 0.02)):
    _, (start, end) = kudio.trim_silence(clip, top_db=30)
    print(start / sr, end / sr)
# 0.448 1.088      the tone runs from 0.5 to 1.0 s
# 0.0 1.5          a DC offset of 0.02: nothing trimmed
# 0.448 1.088

0.02 的偏移比純音的 RMS 低 25 dB,落在 30 dB 的範圍內,所以「靜音」的每個音框都被當成有聲音。請先執行 remove_dc。

在 shell 裡用 kudio trim in.wav out.wav --top-db 30 也能裁切,處理的是檔案混成單聲道後的版本;請看命令列。

背景不是靜音的時候

以最大聲的音框為基準看準位,適用於空檔遠比聲音安靜的情況,例如錄音室錄音,或安靜房間裡的一聲敲擊。在 take.wav 裡,房間的環境音比最大聲的音框低大約 23 dB,所以在預設的 top_db=30 下,每個音框都算有聲音,split_on_silence 會把整個檔案當成一段回傳。把 top_db 調低到環境音被排除為止(這裡是 23),也會切進字詞尾端比較小聲的部分,因為那些部分的準位和環境音差不多。處理語音時,vad、vad_split 和 vad_trim 是拿準位和錄音本身的雜訊底比較,並檢查大聲的音框看起來是不是人聲。語音活動與音高在同一段錄音上把兩種做法都跑了一次。

依標籤剪開

Label 是一段以秒為單位、有名稱的區段,save_labels 會把一串標籤寫成 Audacity 的標籤軌,可以在 Audacity 裡檢查,也能手動移動區段;語音活動與音高用 vad 和 f0 產生標籤,並說明兩種檔案格式。要依標籤檔標的位置剪開錄音,就用 load_labels 讀回來:

import kudio

y, sr = kudio.file_load("take.wav")
for label in kudio.load_labels("take.txt"):
    clip = y[round(label.start * sr):round(label.end * sr)]
    print(label.text, round(label.duration, 2), len(clip))
# speech 1.14 18240
# speech 1.44 23040

把秒換算成樣本數時用 round,不要用 int:2.01 * 16000 算出來是 32159.999…,int 會把它變成 32159,少了一個樣本。

從 Audacity 匯出的標籤軌也能用同樣的方式讀回來。帶有頻率範圍的標籤,Audacity 會多寫一列,以反斜線開頭;load_labels 會略過那一列,因為 Label 只存時間。

音量

normalize 和 normalize_db 設定峰值,分別以滿刻度的比例或 dBFS 表示;靜音的片段則維持不變,不會除以零。gain 和 random_gain 把準位調整固定的 dB 數,或是依種子隨機抽出的 dB 數。四個都保留輸入的 dtype。整數音訊以它自己型別的滿刻度縮放並捨入成整數,增益超過滿刻度時會在滿刻度削峰,而不是溢位繞回。濾波器、淡入淡出、交叉淡化、remove_dc、time_stretch 和 pitch_shift 對整數音訊也是這樣處理:濾波器的過衝,或等功率接縫多出來的 3 dB,都會停在滿刻度,不會繞回變成相反的正負號。

峰值完全看不出片段聽起來多大聲,因為一個突兀的喀聲就能決定峰值。要讓片段聽起來一樣大聲,請用 normalize_lufs,它依 ITU-R BS.1770-4 量測響度;檢查錄音用一段含一聲喀聲的片段示範兩者的差別。

資料擴增

這些函式刻意改變一段錄音,好從中得到更多筆訓練資料。含有任何隨機成分的函式都接受 seed,同一個種子會回傳同樣的陣列;pitch_shift、time_stretch 和 reverb 沒有任何隨機成分。

音高與速度

pitch_shift 把音高移動 n_steps 個半音,長度不變;time_stretch 依 rate 改變長度(大於 1 是變快),音高不變。兩者都來自 librosa。拿一秒鐘、f0 量出來是 200.5 Hz 的 200 Hz 諧波音來試,n_steps=2 會在同樣的 16000 個樣本裡得到 225.0 Hz(升兩個半音應該是 225.1),rate=1.25 則得到 12800 個樣本,音高仍是 200.5 Hz。

以指定 SNR 加入雜訊

add_noise_snr 以 snr_db 把 noise 混進 y。比片段短的雜訊會重複鋪滿;比較長的雜訊則從隨機位置截取,這個位置是其中唯一的隨機成分,所以只有雜訊比較長時 seed 才有作用。雜訊在縮放前會先減去平均值,所以如果雜訊在用到的那一段裡是靜音或常數,就沒有東西可以縮放,不管 SNR 多少都會把 y 原封不動交回;遇到這種情況它會改為引發 FeatureError。兩個訊號都必須是單聲道,立體聲陣列會被拒絕,而不是沿著錯的軸混音。整數音訊以它自己型別的滿刻度混音,回傳時維持原本的型別,並經過捨入和削峰。

import numpy as np
import kudio

sr = 16000
y = (0.3 * np.sin(2 * np.pi * 220 * np.arange(2 * sr) / sr)).astype(np.float32)
noise = np.random.default_rng(0).standard_normal(10 * sr).astype(np.float32)

mix = kudio.add_noise_snr(y, noise, snr_db=5, seed=0)
print(round(kudio.snr(y, mix), 2))                                     # 5.0
print(np.array_equal(mix, kudio.add_noise_snr(y, noise, 5, seed=0)))   # True

SNR 是對整段片段量的,包含停頓。一段一半是靜音的片段以 5 dB 混音時,有聲音的那一半會高 3 dB,大約是 8 dB,因為靜音的那一半讓整段的平均訊號功率減半。整份語料庫的話,Synthesizer 會替每個檔案用各自的種子混音,並記下每個檔案是由哪些來源混成的;請看建立含雜訊資料集。

殘響

reverb 是一個梳狀濾波器:片段本身加上它的七個回聲,間隔 delay_ms(預設 50 ms),每一個是前一個的 decay 倍(預設 0.3,也就是小 10.5 dB)。輸出和輸入一樣長,所以會延續到結尾之後的回聲(預設下最多 350 ms)會被截掉,輸出也經過縮放,讓峰值和輸入的峰值相同。它聽起來像不斷重複的回聲,不像房間:rt60 從它的脈衝響應量出 T30 0.32 秒,並補一句衰減不是直線。資料擴增需要聽起來像房間,或你得說明是哪個房間時,請用 rir 和 apply_rir,在房間與通道。

SpecAugment

spec_augment 會把頻譜圖上隨機的帶狀區域遮掉:最多 freq_mask_width 個頻率格(8)和最多 time_mask_width 個音框(16),預設各一條。每條的寬度是從 0 到最大值之間抽出來的,所以遮罩可能是空的。

它把頻譜圖當成 (frames, bins) 來讀,也就是 kudio 自己由 waveform_to_spectrogram 和 STFT.forward 產生的頻譜圖的排列方式:

import kudio

y, sr = kudio.file_load("take.wav")
spec = kudio.waveform_to_spectrogram(y)                  # (frames, bins)
masked = kudio.spec_augment(spec, seed=0)                # the default widths

被遮掉的格子會填入頻譜圖的平均值,這和 SpecAugment 論文的做法相同:論文先正規化成零平均,再用零遮罩1。如果改成用零去遮未正規化的 log10 功率,不管是什麼片段,都等於功率 1,比它蓋住的幾乎所有東西都亮,所以預設用平均值;要用其他值就傳 mask_value=。

參數在哪裡

這裡每個函式的所有參數,都在 API 參考的編輯與效果底下;Label 和標籤檔則在資料集。


  1. D. S. Park et al., "SpecAugment: A Simple Data Augmentation Method for Automatic Speech Recognition", Interspeech 2019. ↩