[Audio] Python으로 VAD 구현하기

2023. 12. 30. 22:10Developers 공간 [Shorts]/Vision & Audio

728x90
반응형
<분류>
A. 수단
- OS/Platform/Tool : Linux, Kubernetes(k8s), Docker, AWS
- Package Manager : node.js, yarn, brew, 
- Compiler/Transpillar : React, Nvcc, gcc/g++, Babel, Flutter

- Module Bundler  : React, Webpack, Parcel

B. 언어
- C/C++, python, Javacsript, Typescript, Go-Lang, CUDA, Dart, HTML/CSS

C. 라이브러리 및 프레임워크 및 SDK
- OpenCV, OpenCL, FastAPI, PyTorch, Tensorflow, Nsight

 


1. What? (현상)

이번 글에서는 VAD(Voice Activity Detection )를 구현하는 방법을 간단히 소개하겠습니다.

 

여러 개의 source가 섞인 mixture 음성이 있다고 가정합시다. source는 사람의 목소리, 악기 소리 등 어떤 것도 될 수 있습니다. 

이때, mixture 음성에서 필요한 타겟 source 여러개를 각각 분리 해내는 작업은 Source Separation이라고 부를 수 있습니다.

또한, mixture 음성에서 필요한 타겟 source 하나를 추출해내는 것이 목표라면 SE(Speech Enhancement)이라고 부를 수도 있습니다. 그리고 이런 상황에 타겟 source 하나와 나머지 noise로 이루어져있다면, Noise Suppression 혹은 ANC(Active Noise Control)이라고 부를 수도 있습니다. 

** 엄밀히는 ANC는 하드웨어 레벨의 denoising 솔루션(반대 파장으로 능동적으로 제거)이라면, Noise Supprssion은 소프트웨어 레벨의 denoising 솔루션이라고 합니다.

 

이런 Speech EnhancementSpeech Recognition에서의 중요한 component가 VAD입니다. VAD란 오디오 파일 내에서 실제로 음성이 있는 영역을 구분해 찾아내는 task로, 오디오 데이터의 필요없는 부분을 걸러내기 위한 작업입니다. 이는 Speech Enhancement에서는 speech가 아닌 영역을 정확히 찾아야하고, Speech Recognition에서는 Resource를 줄이고 정확도를 올리기 위해 VAD를 사용합니다.

 

Sample 파일은 AIhub(https://aihub.or.kr/aihubdata/data/view.do?currMenu=115&topMenu=100&dataSetSn=130)에서 다운로드 받았습니다. 

 

그 중에 제가 사용한 1분1초차리 파일입니다.

broadcast.wav
1.86MB

 


2. Why? (원인)

  • X

3. How? (해결책)

먼저 VAD가 구현된 git의 링크(https://github.com/wiseman/py-webrtcvad/blob/master/example.py)를 실행해보며 어떻게 동작하는지 살펴보겠습니다.

 

먼저 필요한 패키지들을 설치하겠습니다.

pip3 install pydub webrtcvad g711 numpy

 

먼저 작업환경은 아래와 같은 구조로 되어있습니다.

/mnt/workspace/

├── example.py

├── example.sh

├── vad.py

├── vad.sh
└── broadcast.wav

 

먼저 다운 받은 example.py을 실행하는 example.sh를 실행해보겠습니다. 아래 넣어준 3은 aggressiveness로 얼마나 엄격하게 적용할지에 대한 지표입니다.

** aggressive (0~3) : threshold와 관련된 값으로, 아래 예시를 통해 frame_length(80samples, 160samples, 240samples)의 개념과 함께 자세히 살펴보겠습니다. 아래는 8000Hz인 상황을 예로 듭니다.

  • frame_length 혹은 chunk size가 240 sample 인경우(30ms) : resolution이 coarse하고 VAD가 세세한 변화(<30ms)에 대해 감지하지 못합니다. 즉, 덜 조심(less in-tune)합니다.
  • frame_length 혹은 chunk size가 80 sample 인경우(10ms) : resolution이 fine-grained하고 VAD가 굉장히 정확하게 잡아냅니다. 즉, 더 조심(precise)하게 자릅니다.
  • aggressive가 낮은 경우(0) : threshold를 더 낮게 줍니다. threshold가 낮다는 것은 sample중에 적은 수의 chunk가 '음성'으로 인식되어도 살리겠다는 뜻입니다.
  • aggressive가 높은 경우(3) : threshold를 더 높게 줍니다. sample중에 많은 수의 chunk가 '음성'으로 인식되어야 살리겠다는 뜻입니다.
  • 결과적으로, frame_length 가 작고 aggressive가 높다면 음성을 제외하고 거의 남기지 않겠다는 것이고, frame_length가 높고 aggressive가 낮다면 음성 외에도 거의 다 남기겠다는 뜻이겠죠.
python3 example.py 3 broadcast.wav
더보기

--------------------------------------------------------

<example.py의 원본>

import collections
import contextlib
import sys
import wave

import webrtcvad


def read_wave(path):
    """Reads a .wav file.

    Takes the path, and returns (PCM audio data, sample rate).
    """
    with contextlib.closing(wave.open(path, 'rb')) as wf:
        num_channels = wf.getnchannels()
        assert num_channels == 1
        sample_width = wf.getsampwidth()
        assert sample_width == 2
        sample_rate = wf.getframerate()
        assert sample_rate in (8000, 16000, 32000, 48000)
        pcm_data = wf.readframes(wf.getnframes())
        return pcm_data, sample_rate


def write_wave(path, audio, sample_rate):
    """Writes a .wav file.

    Takes path, PCM audio data, and sample rate.
    """
    with contextlib.closing(wave.open(path, 'wb')) as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sample_rate)
        wf.writeframes(audio)


class Frame(object):
    """Represents a "frame" of audio data."""
    def __init__(self, bytes, timestamp, duration):
        self.bytes = bytes
        self.timestamp = timestamp
        self.duration = duration


def frame_generator(frame_duration_ms, audio, sample_rate):
    """Generates audio frames from PCM audio data.

    Takes the desired frame duration in milliseconds, the PCM data, and
    the sample rate.

    Yields Frames of the requested duration.
    """
    n = int(sample_rate * (frame_duration_ms / 1000.0) * 2)
    offset = 0
    timestamp = 0.0
    duration = (float(n) / sample_rate) / 2.0
    while offset + n < len(audio):
        yield Frame(audio[offset:offset + n], timestamp, duration)
        timestamp += duration
        offset += n


def vad_collector(sample_rate, frame_duration_ms,
                  padding_duration_ms, vad, frames):
    """Filters out non-voiced audio frames.

    Given a webrtcvad.Vad and a source of audio frames, yields only
    the voiced audio.

    Uses a padded, sliding window algorithm over the audio frames.
    When more than 90% of the frames in the window are voiced (as
    reported by the VAD), the collector triggers and begins yielding
    audio frames. Then the collector waits until 90% of the frames in
    the window are unvoiced to detrigger.

    The window is padded at the front and back to provide a small
    amount of silence or the beginnings/endings of speech around the
    voiced frames.

    Arguments:

    sample_rate - The audio sample rate, in Hz.
    frame_duration_ms - The frame duration in milliseconds.
    padding_duration_ms - The amount to pad the window, in milliseconds.
    vad - An instance of webrtcvad.Vad.
    frames - a source of audio frames (sequence or generator).

    Returns: A generator that yields PCM audio data.
    """
    num_padding_frames = int(padding_duration_ms / frame_duration_ms)
    # We use a deque for our sliding window/ring buffer.
    ring_buffer = collections.deque(maxlen=num_padding_frames)
    # We have two states: TRIGGERED and NOTTRIGGERED. We start in the
    # NOTTRIGGERED state.
    triggered = False

    voiced_frames = []
    for frame in frames:
        is_speech = vad.is_speech(frame.bytes, sample_rate)

        sys.stdout.write('1' if is_speech else '0')
        if not triggered:
            ring_buffer.append((frame, is_speech))
            num_voiced = len([f for f, speech in ring_buffer if speech])
            # If we're NOTTRIGGERED and more than 90% of the frames in
            # the ring buffer are voiced frames, then enter the
            # TRIGGERED state.
            if num_voiced > 0.9 * ring_buffer.maxlen:
                triggered = True
                sys.stdout.write('+(%s)' % (ring_buffer[0][0].timestamp,))
                # We want to yield all the audio we see from now until
                # we are NOTTRIGGERED, but we have to start with the
                # audio that's already in the ring buffer.
                for f, s in ring_buffer:
                    voiced_frames.append(f)
                ring_buffer.clear()
        else:
            # We're in the TRIGGERED state, so collect the audio data
            # and add it to the ring buffer.
            voiced_frames.append(frame)
            ring_buffer.append((frame, is_speech))
            num_unvoiced = len([f for f, speech in ring_buffer if not speech])
            # If more than 90% of the frames in the ring buffer are
            # unvoiced, then enter NOTTRIGGERED and yield whatever
            # audio we've collected.
            if num_unvoiced > 0.9 * ring_buffer.maxlen:
                sys.stdout.write('-(%s)' % (frame.timestamp + frame.duration))
                triggered = False
                yield b''.join([f.bytes for f in voiced_frames])
                ring_buffer.clear()
                voiced_frames = []
    if triggered:
        sys.stdout.write('-(%s)' % (frame.timestamp + frame.duration))
    sys.stdout.write('\n')
    # If we have any leftover voiced audio when we run out of input,
    # yield it.
    if voiced_frames:
        yield b''.join([f.bytes for f in voiced_frames])


def main(args):
    if len(args) != 2:
        sys.stderr.write(
            'Usage: example.py <aggressiveness> <path to wav file>\n')
        sys.exit(1)
    audio, sample_rate = read_wave(args[1])
    vad = webrtcvad.Vad(int(args[0]))
    frames = frame_generator(30, audio, sample_rate)
    frames = list(frames)
    segments = vad_collector(sample_rate, 30, 300, vad, frames)
    for i, segment in enumerate(segments):
        path = 'chunk-%002d.wav' % (i,)
        print(' Writing %s' % (path,))
        write_wave(path, segment, sample_rate)


if __name__ == '__main__':
    main(sys.argv[1:])

--------------------------------------------------------

 

실행해보니 잘 되네요. 하지만 "하나 통째의" 오디오 파일을 구분해 chunk파일로 구분해 나왔습니다.

 

우리는 이 1분1초(61초)짜리 오디오를 8초 단위로 나누어서 VAD를 적용한 vad.py을 만들어 보려고 합니다.

먼저 오디오 파일을 받아 데이터를 불러오겠습니다. 8초로 나누니 8개의 chunk들로 이루어질 것입니다.

from pydub.utils import make_chunks
from pydub import AudioSegment

def wav_iter(data):
    # chunk_size=8000ms (8s)
    print("Length : {}".format(len(make_chunks(data,8000))))
    for chunk in make_chunks(data,8000):
        yield chunk.raw_data

wav_file = "./broadcast.wav"
data = AudioSegment.from_file(wav_file)

 

webrtcvad 패키지를 불러와 vad 객체를 만들어보겠습니다. 이번엔 aggressive를 2로 주었습니다.

import webrtcvad
vad_aggressive = 2
vad = webrtcvad.Vad(vad_aggressive)

 

이제 실제로 VAD를 적용해보겠습니다. 먼저 설정값을 살펴보겠습니다.

  • vad_window_time : 몇초 단위로 window를 적용할지에 대한 값입니다. slide를 window time 만큼씩 이동시킵니다.
  • vad_sample_rate : 실제 오디오의 sample_rate가 몇 일지에 대한 값입니다.
  • vad_window_size : 실제로 그래서 몇개의 샘플을 살펴볼 것인지에 대한 "계산 결과"입니다. 예를 들어, 16kHz의 오디오를 10ms동안 살펴본다면 chunk의 개수는 160개 일 것입니다. 하지만 하나의 chunk당 bit per sample이 16bit(2bytes)인 경우 살펴볼 window의 크기는  2를 곱한 320bytes일 것입니다.
    ** vad_window_slide_size는 vad_window_size와 같습니다.
from wav import bytes2numpy, float2int
from example import vad_collector, write_wave

vad_window_time :int = 10 # 10ms
vad_sample_rate = 16000 # 16000Hz
vad_window_size = int((vad_window_time/1000)*vad_sample_rate) #160

for i, chunk_bytes in enumerate(wav_iter(data)):
    # ====================================No needed
    chunk = bytes2numpy(chunk_bytes)
    if i==7: # because length of chunks is 8
        chunk, chunk_length = audio_sample_buffer(chunk, True, None, vad_window_size)
    else:
        chunk, chunk_length = audio_sample_buffer(chunk, False, None, vad_window_size)

    chunkInt = float2int(chunk)
    
    # 32bits(Int) = 16bits(1sample) * 2
    chunk_bytes=chunkInt.tobytes()
    # ====================================
    chunk_frame, chunk_frame_len = frame_parse(chunk_bytes, vad_window_size*2)
    
    # sample_rate, frame_duration_ms, padding_duration_ms, vad, frames
    segments = vad_collector(vad_sample_rate,vad_window_time,210,vad,chunk_frame)

    for i2, segment in enumerate(segments):
        path = 'out/chunk-%002d-%002d.wav' % (i,i2,)
        print(' Writing %s' % (path,))
        write_wave(path, segment, vad_sample_rate)

 

bytes2numpy() 함수, audio_sample_buffer()함수, float2int() 함수, tobytes() 함수는 사실 이 실험에서 필요없는 과정입니다. 근데 아래와 같이 하나의 프로그램으로 처리하는 것이 아니라 stream 서버에서 chunk로 데이터를 bytes로 받게 되는 상황이 오면, 오디오 버퍼(audio buffer)를 활용해 chunk를 만들고 window_size만큼씩 모아서 처리를 해야하기 때문에 해당 상황을 가정하고 넣어두었습니다.

 

또한 frame_parse()함수는 byte데이터들을 window size로 쪼개서 vad에 사용할수 있도록 나누어 넣어주는 역할을 합니다. ** 위 없는 함수들은 아래 아래 더보기를 통해 참조하세요.

 

마지막으로 vad_collector 함수와 write_wave함수는 위에서 살펴보았던 example.py 파일에서 불러와 사용했습니다.

 

기존의 example.py 파일에서 일부 수정된 부분이 있는데, 내용은 아래와 같습니다. 단순히 bytes로 input이 들어오지 않던 것을 bytes데이터일 때로 바꾸어주었습니다.

...

def vad_collector(sample_rate, frame_duration_ms,
                  padding_duration_ms, vad, frames):

	...
    for frame in frames:
        is_speech = vad.is_speech(frame, sample_rate)

        ...
        #sys.stdout.write('1' if is_speech else '0')
        if not triggered:
            if num_voiced > 0.9 * ring_buffer.maxlen:
                ...
                #sys.stdout.write('+(%s)' % (ring_buffer[0][0].timestamp,))
                ...
        else:
            ...
            if num_unvoiced > 0.9 * ring_buffer.maxlen:
                #sys.stdout.write('-(%s)' % (frame.timestamp + frame.duration))
                ...
                yield b''.join([f for f in voiced_frames])
                ...
    #if triggered:
    #    sys.stdout.write('-(%s)' % (frame.timestamp + frame.duration))
    #sys.stdout.write('\n')
    ...
    if voiced_frames:
        yield b''.join([f for f in voiced_frames])

 

실행해보니 8개의 8초짜리 chunk들 중 일부는 VAD가 적용되어 구분되어 잘 나옵니다.

더보기

--------------------------------------------------------

<audio_sample_buffer 함수와 frame_parse 함수의 구현>

 

audio_sample_buffer함수는 chunk들이 window_size까지 모이도록 모아주는 역할해주는 함수입니다. 하지만 위 예시에서 쪼갠 8개의 chunk는 128000, 128000,... 76800 으로 받았으므로, window_size까지 audio_buffer에 쌓아주는 역할은 필요가 없는 상황입니다. 

따라서 사실은 audio_buffer는 공유 될만한 값으로 유지해주어야 하지만, 사실상 지금은 의미가 없어 None으로 입력된 상황입니다.

 

또한 frame_parse 함수는 해당 데이터를 window_size로 쪼개서 frame들로 만들어주는 역할을 해주는 함수입니다.

import numpy as np

def audio_sample_buffer(chunk, is_final, audio_buffer, window_size):
    if chunk is None:
        chunk = np.zeros(window_size)
    if audio_buffer is not None:
        chunk = np.concatenate((audio_buffer, chunk))
        
    # Our case not working, otherwise wait till window_size
    if len(chunk) < window_size:
        if is_final :
            pad = np.zeros(window_size - len(chunk))
            chunk = np.concatenate((chunk, pad))
        else:
            audio_buffer = chunk
            return chunk, len(chunk)
    n_frame = len(chunk)//window_size
    residu_size = len(chunk) - n_frame*window_size
    
    # When window_size is full
    if is_final:
        pad = np.zeros(len(chunk)-residu_size)
        chunk = np.concatenate((chunk, pad))
    else:
        audio_buffer = chunk[n_frame*window_size:]
        chunk = chunk[:n_frame*window_size]
    return chunk, len(chunk)

def frame_parse(audio, window_size):
    frames = list()
    for i in range(len(audio)//window_size):
        frames.append(audio[i*window_size:i*window_size+window_size])
    return frames, len(frames)

--------------------------------------------------------

더보기

--------------------------------------------------------

<데이터의 형식을 변환하기 위한 함수들>

데이터의 형식을 변환해주기 위한 간단한 함수들을 구현해놓았습니다.

import g711
import numpy as np

def wavToFloat(wav):
    return np.array([np.float32((s>>2)/32768.0) for s in wav])

def mulaw2float(wavbytes):
    audio = g711.decode_ulaw(wavbytes)
    return audio

def alaw2float(wavbytes):
    audio = g711.decode_alaw(wavbytes)
    return audio

def bytes2numpy(wavbytes:bytes):
    int_arr = np.frombuffer(wavbytes, dtype=np.int16)
    fp_arr = wavToFloat(int_arr)
    fp_arr = fp_arr.reshape(-1)
    return fp_arr

def float2int(sig, dtype='int16'):
    sig = np.asarray(sig)
    if sig.dtype.kind != 'f':
    	raise TypeError('must be float array')
    dtype = np.dtype(dtype)
    if dtype.kind not in 'iu':
    	raise TypeError('must be integer type')
    i = np.iinfo(dtype)
    abs_max = 2**(i.bits -1)
    offset = i.min + abs_max
    return (sig*abs_max+offset).clip(i.min, i.max).astype(dtype)

--------------------------------------------------------

 


https://stackoverflow.com/questions/55656626/googles-webrtc-vad-algorithm-esp-aggressiveness

https://chromium.googlesource.com/external/webrtc/+/refs/heads/master/common_audio/vad/vad_core.c

https://jin-choi.tistory.com/34

728x90
반응형