Unity/Barracuda & Sentis

Unity Inference Engine을 활용한 Piper TTS (Text To Speech) 구현

pnltoen 2025. 6. 16.
반응형

Piper

Unity Technologies


 

서문

지난 포스팅에서 G2P를 ML 모델을 통해서 Inference 하는 방법에 대해 알아보았습니다 (Unity Inference Engine에서 G2P (Grapheme to Phoneme) 고찰)

 

Unity Inference Engine에서 G2P (Grapheme to Phoneme) 고찰

Inference EngineUnity Technologies서문Kokoro-82M Zero GPU로 테스트해 본 결과 매우 만족스로운 결과를 얻었습니다. 위 문장을 기준으로 0.7초 정도 소요됨 비동기로 처리하면 실시간으로 충분히 사용할 수

pnltoen.tistory.com

 

지난번에 이어서 글을 조금 작성하자면 해당 ML을 이용한 G2P 방식은 몇몇 차이점이 존재합니다.

 

첫번째로, Piper의 공식 예제의 Voices config 파일을 보도록 하겠습니다. 해당 파일을 보면 알 수 있는 점이 다음과 같은 기호로 되어있습니다.

 

    "ʊ": [
      100
    ],
    "ʋ": [
      101
    ],
    "ʌ": [
      102
    ],
    "ʍ": [
      103

 

반면에 mini-bart g2p에서 얻은 결과는 다음과 같습니다.

HH EH1 L OW0

 

즉 mini bart g2p의 출력인 Arpabet을 IPA로 변환해야 합니다. 이 경우 100% 맞는 테이블은 구할 수 없어서 임의로 몇몇 비슷한 발음을 같은 토큰으로 merge 처리 하였습니다.

 

  "ɚ": 60,
  "ɝ": 60,
  
  "ɡ": 66,
  "g": 66,

 

위 60과 66이 대표적인 예시입니다. 60번의 ɚ (ER0)의 경우 강세가 없는 '얼' 발음 그리고 또 60번의 ɝ (ER1)의 경우 강세가 있는 '얼'로 몇몇 강세는 제대로 표현되지 않을 수 있습니다.

 

또한 mini-bart-g2p는 문장이 아닌 단어 단위로 토큰을 생성합니다.

이로 인해, 형용사와 동사에 따라 발음이 달라지는 "use"나, 현재형과 과거형에 따라 발음이 달라지는 "read"와 같은 단어들은 문맥을 고려하지 않고 일반적인 형태로 발음됩니다.

상황에 따라 Piper에서 일부 보정되는 경우도 있으나, 대부분의 경우 TTS 생성시 실제 발음과는 차이가 있는 음성이 생성됩니다.

 

본문

Piper Pre-processes 주의점 

 

기존 다른 포스팅에서 json을 통한 파싱으로 값을 가져오는 예제를 많이 설명해서 Tokenizer 관련은 따로 다루지 않겠습니다.

하지만 조금 특이한 부분이 Piper phonemizer의 경우 다른 TTS 모델의 Input과는 다소 차이가 있었습니다.

 

보통 일반적인 TTS의 경우 Start 및 End 토큰 사이에 실제 토큰을 넣어주는 것이 일반적이나 Piper의 경우 각 토큰 사이에 0을 추가하는 방식을 택합니다 (piper-phonemize) 참고해주시기 바랍니다.

 

GitHub - rhasspy/piper-phonemize: C++ library for converting text to phonemes for Piper

C++ library for converting text to phonemes for Piper - rhasspy/piper-phonemize

github.com

 

하지만 oʊ, aɪ처럼 두 글자가 하나의 음소로 구성된 경우에는 중간에 0을 넣으면 안 됩니다.
이 경우 전체 음소 단위로 패딩을 처리해야 발음 오류를 방지할 수 있습니다.

 

이 부분은 코드로 보는게 쉬울 것 같아서 첨부합니다.

만약 2개이상의 음소로 표현된 경우 foreach 문에서 TryGetValue 후 복합 음소 처리 후에 0을 넣어주는 것이 좋습니다.

        foreach (string ipa in ipaTokens)
        {
            foreach (char c in ipa)
            {
                if (IPAToIDs.TryGetValue(c.ToString(), out int id))
                {
                    allPhonemeTokenIDs.Add(id);
                }
            }
            allPhonemeTokenIDs.Add(0);
        }

 

마지막으로 mini bart g2p는 마침표, 쉼표, 느낌표, 물음표 같은 기호를 처리하지 않습니다. 해당 부분은 Piper에서는 입력 토큰으로 받기 때문에 별도의 처리로 살려줘야 합니다.

텍스트: "Are you okay?"

MiniBART G2P 출력: AA R Y UW OW K EY   // ?는 없음
Piper 기대 IPA: ɑɹ ju oʊˈkeɪ ?         // ?는 포함됨

 

 

저의 경우에는 punctuationSlots을 별도로 생성해서 처리하였습니다.

 

위 부분이 모두 진행되지 않을 경우 매우 이상한 output을 출력하게 됩니다.

 

piper_output.wav
0.13MB

 

음성을 들어보면 원문은 "I love playing the piano"로 입력을 하였지만 실제 출력을 "lo pl th po" 처럼 들립니다. 즉 정확한 패딩을 하지 못하는 원인이 되기 때문에 주의하시기 바랍니다.

 

사실 저의 경우에는 문서를 제대로 보지 않아서 이 부분에서 시간을 많이 날렸는데 특히 헷갈렸던 부분이 Piper에는 Scales 값을 받습니다. 해당 스케일 값은 일반적인 TTS와 같이 noise_scale, length_scale, noise_w로 되어 있습니다.

 

따라서 처음에는 length_scale도 늘려보고 아니면 audio의 mono가 아닌가 또는 audio를 생성할 때 유실되는 tensor가 있나 모두 확인해보았는데... (심지어 유니티 defaul audio sample 설정도 건들여봄...) 그냥 패딩 문제였습니다. 참고하시기 바랍니다.

 

Inference 관련

실행과 관련해서는 구현 부분에서 특별한 부분은 없습니다. output tensor를 오디오로 재생하면 끝이기 때문에 방식은 매우 간단한 편입니다. 다만 조금 신경썼던 부분은 성능과 관련한 부분이였습니다.

 

전체적인 Workflow를 고려해서 볼때 mini-bart-g2p의 decoder는 auto regressive 방식으로 [2,0] 초기 토큰을 넣은다음 계속 그 값을 비교해야 합니다. 즉 CPU로 계속 내려서 값을 비교해야 하기 때문에 Mini-bart-g2p의 Encoder 및 Decoder는 CPU에서 실행하는 것이 더 빨랐습니다. 

 

이는 GPU로 연산했을 떄 얻는 실제 연산 시간보다, GPU에서 연산 후 CPU로 Download 하는 과정에서 발생하는 delay가 더 크기 때문입니다.

 

전체적인 그림을 볼때 아래와 같이 진행됩니다 (함수 기준)

아래는 2개 문장의 예시이고 Input 특성상 길이에 따라 성능은 달라질 수 있습니다.

 

Tokenizer (0.23ms) -> Encoder (10.72ms) -> Decoder (83.78ms) -> DecodeToIPAIDs (1.6ms) -> Run Piper (162.83) 

 

프로파일링을 돌려보면 1프레임이 몰아서 처리하다 보니 cost가 확 튀고 267.95ms를 기록한 것을 확인할 수 있습니다.

 

 

따라서 Tokenizer 이후에는 프레임당 단어 하나씩 Encoder → Decoder → IPA 변환 순으로 처리하도록 구성했습니다.

특히 Decoder의 경우 Auto regressive 방식으로 하나의 프레임에서 다음 프레임으로 넘기기가 쉽지 않은 구조이며 메모리 누수의 가능성도 높습니다. 기본적으로 단어 하나의 경우 큰 무리가 되지 않기 때문에 프레임당 처리하였습니다.

 

    IEnumerator RunModel(List<string> tokens)
    {
        for (int i = 0; i < tokens.Count; i++)
        {
            RunEncoder(tokens[i]);
            RunDecoder(encoder_hidden_states, attentionMask_bart);
            DecodeToIPAIDs(decoderTokens, tokenCount, i);

            yield return null;
        }
        StartCoroutine(RunPiper());
    }

 

이와는 반대로 Piper의 경우 Inference 시 Async로 처리하면 안되는 별다른 이유가 없기 때문에 Inference Engine에서 지원하는 Async 방식으로 ScheduleIterable 및 ReadbackAndCloneAsync 즉 Async 처리하였습니다.

 

    IEnumerator RunPiper()
    {
        int count = allPhonemeTokenIDs.Count;
        allPhonemeTokenIDs.Insert(0, 1);  // START
        allPhonemeTokenIDs.RemoveAt(allPhonemeTokenIDs.Count - 1);
        allPhonemeTokenIDs[allPhonemeTokenIDs.Count - 2] = 2;
        var phonemes_Ids = allPhonemeTokenIDs.ToArray();
        allPhonemeTokenIDs.Clear();

        var piper_input = new Tensor<int>(new TensorShape(1, phonemes_Ids.Length), phonemes_Ids);
        var piper_input_lengths = new Tensor<int>(new TensorShape(1), new int[] { phonemes_Ids.Length });
        var piper_scales = new Tensor<float>(new TensorShape(scales.Length), scales);

        piper_Async = piper_Worker.ScheduleIterable(piper_input, piper_input_lengths, piper_scales);

        int it = 0;
        while (piper_Async.MoveNext())
        {
            if (++it % k_LayersPerFrame == 0)
                yield return null;
        }
        yield return null;

        var piper_output = piper_Worker.PeekOutput("output") as Tensor<float>; // batch x Channel x Height x Time
        var awaiter = piper_output.ReadbackAndCloneAsync().GetAwaiter();

        awaiter.OnCompleted(() =>
        {
            var piper_CPUoutput = awaiter.GetResult().DownloadToArray();
            PlayAndSavePiperAudio(piper_CPUoutput);

            piper_output.Dispose();
            piper_input.Dispose();
            piper_input_lengths.Dispose();
            piper_scales.Dispose();
        });
    }

 

결과적으로 Async 처리 이후, 아래와 같이 프레임당 최대 15~20ms 이내로 처리가 가능해졌습니다.
해당 수치는 프로젝트에 따라 layerPerFrame 값을 조정함으로써 더 줄이거나 더 늘릴 수 있습니다.

 

 

 

 

그 외...

앞서 설명하였던 TTS의 voice 관련 scales 관련해서 해당 scales로 Inference 자체가 deterministic하게 진행되지 않았습니다.

즉 이 말은 Random한 inference 결과를 얻게 된다는 점인데 Inference Engine 초기 버전의 경우 GPU 해당 부분을 지원하지 않는 문제가 있었습니다. 다행스럽게도 빠른 수정이 이루어져서 큰 문제는 없었습니다 (전체 CPU 처리할 뻔...)

 

결론

추가적으로 Asset Store의 에셋을 활용하여 다음과 같이 UI를 배치하였습니다.

 

 

전반적으로 Piper TTS의 경우 기존 공식 예제인 jets TTS와 달리 보이스를 선택할 수 있다는 점이 큰 차이점이기 때문에 해당 부분으로 UI를 만들었습니다. 실제 실행한 영상은 다음과 같습니다.

 

 

반응형

댓글