본문 바로가기

Study/프로젝트

중국어 성조 분석-학습 프로그램

0. 중국어의 구조와 성조 언어

 중국어는 성조 언어이다. 성조언어란, 같은 음의 발화여도 음높이의 변화에 따라 의미를 구별하는 것을 뜻한다. 중국어나 기타 성조언어를 공부해보지 않은 한국인 화자라면, 직감적으로 이해가 안될 수 있다. 아래에 다시 설명하도록 하겠다. 

중국어의 구조

 많은 사람이 알다싶이 중국어의 표기문자는 한자이다. '좋다'라는 의미는 好로 표기할 수 있다.

 대표적으로 한국어와 영어는 표음문자로 보는 그대로 읽을 수 있지만, 한자는 표의문자이기 때문에 (정확히는 반표음반표의) 병음-pinyin 으로 중국어의 발음을 표기한다. 영어와 발음이 비슷하지만 일부 다른 요소가 존재한다. 위의 글자는 읽혀지는 대로 '하오'라고 읽을 수 있다.

 

 위의 표기에서 볼 수 있듯이, 병음 중 붉게 표시된 부분이 성모, 하늘색으로 표시된 부분이 운모이다. 한글로 따지자면 각각 자음과 모음으로 생각할 수 있다. 성모는 b, p, m, f 등 21개, 운모는 yi, yu, a, o 등 15개로 총 36개로 구성 되어 있다.

 

 어렸을때 구몬 한자 공부를 했을때를 기억하면 한자 단어가 적어도 몇만개 존재한다는 것을 알 수 있다. 하지만 이렇게 총 각 21 - 15 의 조합으로 나타낼 수 있는 발음에는 한계가 있다. 따라서 여기에 사용되는것이 음높이 변화 성조이다.

 논문에 따르면 세계 언어의 60~70%는 성조언어에 속한다고 한다. 또한 대표적인 음높이 변화로는 

 1. 수평조 2. 단순굴곡조 (오름조, 내림조) 3.복합굴곡조(오르내림조, 내리오름조)가 존재한다고 한다. 

중국어의 각 성조의 음높이  출처:xinsound.tistory.com

 위의 그림이 중국어 각 성조의 음높이와 음높이 변화이다. 

1성은 높은 음을 유지하는 수평조, 2성은 중간음에서 높은음으로 올라가는 단순굴곡조 - 오름조, 

3성은 낮음으로 내려갔다 높은음으로 향하는 복합굴곡조 - 내리오름조, 

4성은 높은음에서 낮음으로 내려가는 단순굴곡조 - 내림조에 해당한다.

 

 앞서 말한듯, 성조언어에 접해보지 못한 사람들은 직관적으로 이해가 안될 수 있다. 한국인 화자에게 성조 언어가 가장 쉽게 이해될 수 있는것은 경상도 사투리일 것이다.

이미지 출처: https://www.dogdrip.com/bbs/board.php?bo_table=drip&wr_id=336746

 위의 사진에서, '가'라는 하나의 음절은 각각의 음높이에 따라 서로 다른 의미를 지니며, 문장을 이룰 수 있다. 이와같이 우리나라 한국어에도 지방 사투리에서는 성조가 존재하기에 우리나라 언어도 부분 성조언어로 여겨진다.

 중국어에는 이런 성조가 더욱 두드러지고, 하나의 성모-운모 조합에도 많게는 수십가지의 의미가 존재한다. 

 예시로, 위의 두 단어 好와 号는 같은 성모와 운모의 조합(h-ao)으로 모두 '하오'로 읽히지만, 각각 '좋다'와 '번호'의 뜻을 지닌다.

 

1. 음성파일 입력

먼저, 음성파일 분석을 위해 음성파일을 입력 받았다. 필요한 데이터는 음높이(pitch) 데이터이다.

이를 위해 파이썬 parsel-mouth 패키지를 활용했다. parsel-mouth는 기존의 유명한 음성 분석 소프트웨어 praat를 파이썬에서 활용할 수 있는 패키지이다.

def load_file(file_path):
    import parselmouth
    snd = parselmouth.Sound(file_path)
    pitch = snd.to_pitch()
    pitchs = pitch.selected_array['frequency']
    pitchs[pitchs == 0] = np.nan
    return pitchs

파일 경로를 입력하면, parsel_mouth 패키지를 활용하여, 주파수 성분의 pitch 데이터를 가져오고, 음높이가 0인 지점 즉, 발화가 없는 지점은 nan값으로 교체해 주었다. 이때, pitch 데이터는 음높이 값으로 numpy array 형태로 저장되어 있다.

paresl_mouth로 받은 음성 데이터 정보는 아래 박스를 통해 확인할 수 있다.

 

 또한, 녹음된 파일을 speech-to-text(STT)기법을 사용하여 발화된 문장을 문자형태로 받았다.

파이썬에서는 speech_recognition 패키지를 활용할 수 있다. 이 패키지에는 google을 비롯한 몇개의(6개?) recognizer가 있다. 여기서는 google recognizer를 사용하였다. 

def get_sentence(file_path):

    import speech_recognition as sr

    r = sr.Recognizer()

    sample = sr.AudioFile(file_path)
    with sample as source:
        audio = r.record(source)

    msg = r.recognize_google(audio,language = 'zh-CN')
    return msg

recognizer 객체를 만들고 어떤 recognizer를 사용할지를 선택한 후, 음성 데이터와 변환하고자하는 언어 코드를 입력해주면 변환해 준다. 추가적으로 구글 클라우드 플랫폼(GCP)에서도 각각 STT와 TTS를 활용 할 수 있는데 이를 활용할 경우 각 음절에 대한 time stamp를 받아 볼 수 있다. (하지만 정확도가 그렇게 좋은것 같진 않았다)

 

2. 음성데이터 가공

이제 음높이 분석을 위해 데이터를 가공할 것이다. 가공은 다음 순서로 진행된다.

1. 음절단위로 음높이 데이터 나누기, 이상치 제거

2. 띄어읽기 일치화

3. 음길이 일치화 

4. 노이즈제거, 5도제 정규화

사용 데이터는 중국인 남성화자와 한국인 여성 화자(비전공, 중국어 학습기간: 2년)의 음높이 데이터를 준비했다. 

제시된 문장은 我喜欢苹果이다.

Raw Data

아무 가공되지 않은 데이터이며, 한국인 여성화자가 파랑색, 중국인 남성 화자가 붉은색 그래프이다. 

2-1 이상치 제거, 음절단위로 음높이 데이터나누기

앞서 말한듯 음높이가 0인 즉, 음성이 녹음되지 않은 부분은 nan으로 바꿔준다. 또한 정상적인 말소리가 아닌 잡음으로 인한 소리로 추정되는 데이터는 삭제하였다. 시간에 따라 음높이가 1차원 numpy array로 저장된 데이터를 각 음절에 따라 2차원 배열로 만들었다. 

def Sep_Words(pitch_values):
    
    not_nan_index = np.argwhere(np.logical_not(np.isnan(pitch_values))).reshape(-1)
    #pich_values에서 nan이 아닌것의 index들을 1차원으로 표현

    diff_word_idx = [not_nan_index[0]] #가장 첫 발화 음높이 index추가
    for i in range(len(not_nan_index)-1): #not nan index 에서 다음 값이 +1 이 아닌것들을 append
        if not_nan_index[i]+1 != not_nan_index[i+1]:
            diff_word_idx.append(not_nan_index[i])
            diff_word_idx.append(not_nan_index[i+1])
    diff_word_idx.append(not_nan_index[-1]) # 가장 마지막 발화 음높이 index 추가
    
    cluster = [] #발화 상 나눠지는 단어수를 저장할 list 생성
    for i in range(0,len(diff_word_idx),2):
        cluster.append(pitch_values[diff_word_idx[i]:diff_word_idx[i+1]+1])
        #pitch_values에서 발화 뭉치들을 묶어 word_clust에 저장
    
    return cluster

 

1. 음높이 값이 저장되어 있는 곳에서 nan이 아닌 값들의 index정보들을 1차원 배열 not_nan_index에 저장한다. 

2. 각 단어의 시작과 끝의 위치정보를 저장할 diff_word_idx를 생성하고, 첫 음절의 시작점을 추가해준다.

3. not_nan_index에는 전체 pitch_values에서 유효한 값들의 index값만 가지고 있다. not_nan_index 배열을 순회하며 현재 값이 뒤에있는값과 차이가 1이 아니라면, 다른 음절에서의 발화를 의미한다. 따라서 현재 음절이 끝나는 위치값과 다음 음절의 시작 위치값을 diff_word_idx에 저장해준다.

4. 마지막 음절의 끝나는 위치값을 diff_word_idx에 추가해 준다.

5. 각 단어의 음높이를 각 차원에 저장할 cluster 배열을 생성해주고, 유효한 음높이값의 위치가 저장된 diff_word_idx를 이용하여 pitch_values 배열에서 슬라이싱 해준다. 

 

nan값들을 삭제하면서, 각 단어의 음높이값을 구분하기 위해 생각해냈는데 사실 후입선출 구조의 Stack 자료구조를 이용하면 좀더 쉽게 구현할 수 있다. 

def remove_short_term_noise(spitch_cluster,pp = 0.3):

    time_for_words = []
    for spitchs in spitch_cluster:
        time_for_words.append(len(spitchs))

    time_for_words = np.array(time_for_words)
    bar = np.mean(time_for_words)
    stand = int(bar * pp)

    idx = np.where(time_for_words < stand)
    new_cluster = np.delete(spitch_cluster,idx)

그다음으로 너무 짧게 입력된 값들은 주변의 잡음, 필요없는 음이라 생각하고 삭제하였다.

2차원 배열로 저장된 음높이 값들의 각각의 길이(녹음된 각 음성의 길이)를 계산해서 일정 비율 이하는 삭제했다.

 

전(좌), 후(우)

전과 후를 보면, 기존 녹음된 잡음을 삭제했고, 음높이를 봤을때 보다 직관적인 확인이 가능해졌다.

2-2 형태소 단위로 묶기

중국어는 띄어쓰기가 없는 언어이다. 같은 문장을 발화하더라도 의미, 강조, 화자의 문법적 지식 등에 의해 띄어 읽는 부분이 달라질 수 있다. 예를들어 한국어는 '나는 사과를 좋아한다' 라고 띄어쓰기를 통해 띄어 읽는 부분이 비교적 명확하게 드러나는 반면 중국어에서는 '我喜欢苹果‘로 띄어 쓰기가 없다.

또한, 위의 예시 자료에도 중국인 화자 음성파일은 중국인 친구가 굉장히 천천히 발음해줬기 때문에 형태소 단위로 발음한 것이 아닌 각 음절 단위로 발음하여 녹음되었다. 

보다 직관적인 이해를 위해 형태소 단위로 음높이 값들을 묶어주었다.

def catenate_n(arr,idx):
    for i in range(0,len(idx),2):
        arr[idx[i]] = np.concatenate((arr[idx[i]], arr[idx[i+1]]) , axis = None)
    
    for i in range(len(idx)-1,0,-2):
        arr = np.delete(arr,idx[i])
    return arr

 numpy의 concatenate함수를 이용해서 2차원으로 저장된 음높이 값들에서 지정된 인덱스 값들을 하나로 뭉쳐주었다.

위의 한국인 화자 데이터로 예를 들자면, [2,3] 번을 같이 묶어 주었다.

--> 예시 문장은 '我喜欢苹果' 이나 苹果를 형태소 단위가 아닌 苹과果 각각 음절단위로 끊어 읽었다.

이렇게 원 문장 我喜欢苹果를 각각 형태소 단위로 [ 我-喜欢-苹果 ] 의 음높이 값으로 각각 묶어주었다.

이제 보다 직관적으로 자신의 성조 음높이 변화와 중국인 성조 음높이 변화를 확인이 가능해졌다.

2-3 길이 일치화

이제 각각 형태소 단위로 음높이 값들이 묶여졌지만, 각 형태소 혹은 음절의 발화 길이가 다르다. 

한국인 화자는 我를 비교적 짧게 발음한 반면 중국인 화자는 좀더 길게 발음해 주었다. 

이 길이를 맞춰주기 위해, 이미지 blur처리 할때와 같은 알고리즘을 이용했다.

def pitch_blur(arr2d_1, arr2d_2):
    for i in range(len(arr2d_1)):
        try:
            arr1 = arr2d_1[i]
            arr2 = arr2d_2[i]

            n1 = len(arr1)
            n2 = len(arr2)

            big_n1 = n1 > n2
            if big_n1:
                for time in range(n1 - n2):
                    idx = np.random.randint(0,n2)
                    sup = np.mean((arr2[idx-1],arr2[idx]))
                    arr2 = np.insert(arr2,idx,sup)
            else:
                for time in range(n2 - n1):
                    idx = np.random.randint(0,n1)
                    sup = np.mean((arr1[idx-1],arr1[idx]))
                    arr1 = np.insert(arr1,idx,sup)

            arr2d_1[i] = arr1
            arr2d_2[i] = arr2
        except:
            pass
    return arr2d_1, arr2d_2

두 명의 화자 발화가 각각 형태소 단위로 저장되어 있다(arr2d_1, arr2d_2)

각 음높이 뭉치(arr1,arr2)에서 길이가 긴 것을 찾고, 짧은 뭉치를 긴 뭉치에 맞춰준다.

짧은 뭉치에서 임의의 위치에 앞과 뒤의 값의 평균 값을 삽입하여 길이를 맞춰준다.

* 두 화자의 발화 데이터의 형태소 개수가 같아야 정상적으로 수행된다 ( len(arr2d_1) == len(arr2d_2) )

2-4 높이 변화 추세, 5도제 정규화

성조는 음높이 변화를 통해 의미를 구별하는 것이다. 따라서 음높이의 변화를 나타내기 위해서 이동평균법을 사용하여 음높이의 변화를 추출하였다. 해당 데이터는 0.01초 단위로 음높이가 저장된 것이다. 여기서는 5개의 값(0.05초)들을  묶어 음높이의 증가/감소 추세 곡선을 구성하였다.

 

또한, 샘플 데이터는 한국인 여성, 중국인 남성의 데이터를 사용하였다. 그래프에서 볼 수 있듯이 성별과 개인의 차이에서 오는 기본 음높이의 차이가 존재한다. 5도제 정규화를 통해 이러한 남성과 여성 등 개인의 차이에서 오는 기본 음높이 차이를 제거하고, 중국어 성조의 표준 표현법으로 표현한다.

def MA(pitch_value,n):
    rolling = []
    for i in range(len(pitch_value)-n):
        window = pitch_value[i:i+n]
        rolling.append(np.mean(window))
    rolling = np.array(rolling)
    pitch_max , pitch_min = np.nanmax(rolling), np.nanmin(rolling)
    rolling_pitch = ( ( rolling - pitch_min) / (pitch_max - pitch_min)) * 5
    return rolling_pitch

 

 

3. 점수표시

원래 계획한 것은 성조에 미숙한 외국인 학습자에게 자신의 음높이 변화를 시각적으로 확인하고, 성조학습을 돕는 것이였다. 따라서 자신과(외국인 화자) 원어민(중국인 화자)의 음높이 변화의 유사도를 측정하고 이를 점수로 표현하여 보다 학습에 도움이 되고자 하였다.

def get_diff_2d(arr_2d):
    diff = []
    for pitch in arr_2d:
        _tmp = []
        for i in range(len(pitch)-1):
            _tmp.append(pitch[i+1] - pitch[i])
        diff.append(np.array(_tmp))
    
    return np.array(diff)
    
def cos_sim(A, B):
       return np.dot(A, B)/(np.linalg.norm(A)*np.linalg.norm(B))

def scoring(diff2d_1, diff2d_2):
    for i in range(len(diff2d_1)):
        arr1 = diff2d_1[i]
        arr2 = diff2d_2[i]
        score = cos_sim(arr1,arr2)
        if score >= 0.5:
            print('{} 번째 단어 Great! 정확도 : {}'.format(i+1, np.round(score * 100 ,2)))

코사인 유사도를 통해 두 그래프의 유사도를 측정하고, 높은 유사도를 보인 단어에 대해 출력하도록 하였다.

풀코드

import parselmouth
import numpy as np
import matplotlib.pyplot as plt 

def load_file(file_path):
    import parselmouth
    snd = parselmouth.Sound(file_path)
    pitch = snd.to_pitch()
    pitchs = pitch.selected_array['frequency']
    pitchs[pitchs == 0] = np.nan
    return pitchs

def MA(pitch_value,n):
    rolling = []
    for i in range(len(pitch_value)-n):
        window = pitch_value[i:i+n]
        rolling.append(np.mean(window))
    rolling = np.array(rolling)
    pitch_max , pitch_min = np.nanmax(rolling), np.nanmin(rolling)
    rolling_pitch = ( ( rolling - pitch_min) / (pitch_max - pitch_min)) * 5
    return rolling_pitch

def get_sentence(file_path):
    import speech_recognition as sr

    r = sr.Recognizer()

    sample = sr.AudioFile(file_path)
    with sample as source:
        audio = r.record(source)

    msg = r.recognize_google(audio,language = 'zh-CN')
    return msg

def Sep_Words(pitch_values):
    
    not_nan_index = np.argwhere(np.logical_not(np.isnan(pitch_values))).reshape(-1)

    diff_word_idx = [not_nan_index[0]]
    for i in range(len(not_nan_index)-1): 
        if not_nan_index[i]+1 != not_nan_index[i+1]:
            diff_word_idx.append(not_nan_index[i])
            diff_word_idx.append(not_nan_index[i+1])
    diff_word_idx.append(not_nan_index[-1]) 
    
    cluster = [] 
    for i in range(0,len(diff_word_idx),2):
        cluster.append(pitch_values[diff_word_idx[i]:diff_word_idx[i+1]+1])
    
    return cluster

def catenate_n(arr,idx):
    for i in range(0,len(idx),2):
        arr[idx[i]] = np.concatenate((arr[idx[i]], arr[idx[i+1]]) , axis = None)
    
    for i in range(len(idx)-1,0,-2):
        arr = np.delete(arr,idx[i])
    return arr

def change_1d(arr_2d):
    new_1d = []
    sep = [np.nan] * 20
    for item in arr_2d:
        new_1d += list(item)
        new_1d += sep
    
    return np.array(new_1d)

def remove_short_term_noise(spitch_cluster,pp):
    
    time_for_words = []
    for spitchs in spitch_cluster:
        time_for_words.append(len(spitchs))

    time_for_words = np.array(time_for_words)
    bar = np.mean(time_for_words)
    stand = int(bar * pp)

    idx = np.where(time_for_words < stand)
    new_cluster = np.delete(spitch_cluster,idx)

    return new_cluster

def pitch_blur(arr2d_1, arr2d_2):
    for i in range(len(arr2d_1)):
        try:
            arr1 = arr2d_1[i]
            arr2 = arr2d_2[i]

            n1 = len(arr1)
            n2 = len(arr2)

            big_n1 = n1 > n2
            if big_n1:
                for time in range(n1 - n2):
                    idx = np.random.randint(0,n2)
                    sup = np.mean((arr2[idx-1],arr2[idx]))
                    arr2 = np.insert(arr2,idx,sup)
            else:
                for time in range(n2 - n1):
                    idx = np.random.randint(0,n1)
                    sup = np.mean((arr1[idx-1],arr1[idx]))
                    arr1 = np.insert(arr1,idx,sup)

            arr2d_1[i] = arr1
            arr2d_2[i] = arr2
        except:
            pass
    return arr2d_1, arr2d_2

def get_diff_2d(arr_2d):
    diff = []
    for pitch in arr_2d:
        _tmp = []
        for i in range(len(pitch)-1):
            _tmp.append(pitch[i+1] - pitch[i])
        diff.append(np.array(_tmp))
    
    return np.array(diff)

def ma_2d(arr_2d,n):
    roll_n = []
    for pitch in arr_2d:
        _tmp = []
        for i in range(len(pitch)-n):
            mask = pitch[i:(i+n)]
            _tmp.append(np.mean(mask))
        roll_n.append(np.array(_tmp))
    return np.array(roll_n)

def cos_sim(A, B):
       return np.dot(A, B)/(np.linalg.norm(A)*np.linalg.norm(B))

def scoring(diff2d_1, diff2d_2):
    idx = min((len(diff2d_1),len(diff2d_2)))
    cnt = 0
    for i in range(idx):
        arr1 = diff2d_1[i]
        arr2 = diff2d_2[i]
        score = cos_sim(arr1,arr2)
        if score >= 0.5:
            cnt += 1
            print('{} 번째 단어 Great! 정확도 : {}'.format(i+1, np.round(score * 100 ,2)))
    if cnt == 0:
        print('분발하세요')
        
def draw_pitch(pitch_value,cr = 'black'):
    x = np.arange(len(pitch_value))
    plt.plot(x,pitch_value,color = cr)
    
def draw_two_pitch(p1,ch):
    x1 = np.arange(len(p1))
    x2 = np.arange(len(ch))
    
    plt.plot(x1,p1,color = 'blue',label = 'You')
    plt.plot(x2,ch, color = 'r',label = 'Chinese')
    plt.legend()

잡설

더보기

막학기에 본 전공인 중국학과 그동안 배운 통계와 프로그래밍으로 그럴싸한 프로그램을 만들어보려 하였지만, 아쉬운점이 분명 많이 있다. 

1. 형태소 단위로 묶을때의 자동화 필요

2. 실시간 음성입력 기능 구현

3. 음길이 일치화에 더 나은 알고리즘 필요

한 학기 수업을 들으면서 실질적인 프로젝트기간은 1달 반정도로 아쉬운 부분이 많이 있지만, 좀 더 공부하여 보완해 봐야겠다..