Voice Cloning
🕒 읽는 데 0분 예상forest_articles
[”블로그에 음성으로 읽어주기 기능 추가하기”]
forest_날짜
forest_분류
문서
음성 복제 또는 딥페이크 오디오라고도 하는 오디오 딥페이크 기술은 특정 개인을 설득력 있게 모방하는 음성을 생성하도록 설계된 인공 지능의 응용 프로그램으로, 종종 해당 개인이 말한 적이 없는 문구나 문장을 합성합니다.
Models
‣
블로그에 음성으로 읽어주기 기능 추가하기
처음에는 Elevenlabs의 Voice-cloning 솔루션을 사용하여 게시글을 내 목소리를 학습한 AI 음성으로 읽어주는 기능을 구현해두었다. 그런데 이를 위해서는 매달 5달러를 내야 한다. 방문자도 별로 없는데… 7천원이면 스타벅스 자바칩 프라푸치노를 마실 수 있다.
그래서 어느새부터는 내 목소리 클론 모델이 아닌 기본 제공 모델로 대체해 음성으로 듣기 기능을 제공하고 있었다. 그러나 내 목소리를 내는 TTS, 이거 포기하기에는 매우 재밌는 뻘짓이다. 사실 이미 Elevenlabs와 같은 외부 솔루션이 아니라 로컬에서 모델 학습부터 추론까지 내 목소리로 학습시켜 구현해둔 프로젝트가 있긴 하다. 놀랍게도 고등학교 때 생기부에 쓴 내용이다. 하지만 이게 벌써 2년 가까이 지났으니 최근 나온 zero-shot Voice Cloning 모델들에 비하면 품질이 현저히 떨어진다. 당시에 30분에 달하는 음성을 녹음하여 학습시켰는데도 말이다(이것도 권장 분량보다 적은 거였다). 따라서 새로운 오픈소스 모델을 사용해 TTS를 구현해보기로 했다.
Voice Cloning 구현하기
먼저 찾은 모델은 coqui의 XTTS-v2라는 모델이다. 10초 내외의 짧은 레퍼런스 오디오를 통해 보이스 클론이 가능하다. 다만 단점이라면 한글 기준 한 번에 생성할 수 있는 문장은 95자 이하라는 것이다. 이를 해결하기 위해 긴 텍스트를 입력받아 95자 이하의 세그먼트로 분할하고, 각 세그먼트에 대해 문장을 생성하여 다수의 음성 파일들을 생성한다. 이후 이 음성 파일들을 순서에 맞게 병합하여 하나의 오디오 파일로 만드는 방식이다.
# 텍스트를 </ttsSplit> 단위로 분리하는 함수 def split_text(text): sentences = text.split("<ttsSplit/>") return [sentence.strip() for sentence in sentences if sentence.strip()] reference_audios = ["./ref.wav"] # 출력 폴더 생성 (없으면) 및 기존 파일 삭제 # (생략) # 긴 텍스트를 문장 단위로 분리 sentences = split_text(text_to_speak) # 각 문장에 대해 음성을 합성하고 개별 파일로 저장 for idx, sentence in enumerate(sentences): output = model.synthesize( sentence, config, speaker_wav=reference_audios, gpt_cond_len=45, language="ko", ) wav_data = output['wav'] out_file_path = os.path.join("./outputs", f"output{idx + 1}.wav") wavfile.write(out_file_path, 24000, wav_data) # ./outputs 폴더에 있는 wav 파일들을 output뒤에 있는 숫자 순서대로 병합하여 하나의 wav 파일로 생성 print("오디오 병합 시작") output_files = [f for f in os.listdir("./outputs") if f.startswith("output") and f.endswith(".wav")] output_files.sort(key=lambda f: int(re.search(r"output(\d+)\.wav", f).group(1))) silence = AudioSegment.silent(duration=500) combined_audio = AudioSegment.empty() for file in output_files: audio_segment = AudioSegment.from_wav(os.path.join("./outputs", file)) combined_audio += audio_segment combined_audio += silence # 각 audio segment 뒤에 0.5초 묵음 추가 combined_audio_speedup = combined_audio.speedup(playback_speed=1.1) combined_audio_speedup.export("./outputs/combined_output.wav", format="wav")
더 좋은 품질의 음성을 합성하기 위해서는 텍스트 정규화(Normalization)이 필요하다. 예를 들어 “1984년”이라는 텍스트가 있다면 이를 “천구백팔십사년”으로 변환해주는 과정을 말한다. 이를 별도의 규칙 기반 또는 신경망 기반 모델을 이용하여 진행할 수도 있지만, 비용과 품질 등에서의 효율을 위해 LLM을 사용하기로 했다. Google AIStudio의 Gemini Experimental 1206 모델을 사용하였다. 사용한 System Prompt는 아래와 같다.
한국어 텍스트를 입력받아 다음과 같은 규칙에 따라 정규화된 결과를 반환한다: - 숫자, 영어 표기 변환: 숫자는 한글 발음으로 변환한다. 예시: "352" → "삼백오십이" - 한글 발음법에 따라, 상황에 맞는 숫자 읽기 방식을 적용한다. 단독 숫자: "하나", "둘" 등 서술적 숫자: "일", "이", "삼" 등 - 영어 표기 및 기술 용어는 한글 발음으로 변환한다. 예시: "k6" → "케이식스", "http" → "헤이치티티피" - 기술 용어: 통상적으로 사용되는 한국어 발음을 따름. - 특수문자 변환: 구두문자를 제외하고, 특수문자를 한글로 변환한다. 예시: "&" → "엔드", "%" → "퍼센트" - 소스코드로 보이는 부분은 제거한다. - 괄호 안의 텍스트는 제거한다. - 모든 문장은 완전한 형태로 재작성하며 종결 어미는 '~다'를 유지한다. - 제목이나 소제목이라 판단되는 문장에는 끝에 마침표를 추가한다. - 텍스트를 95자 이하로 나눈다: 문장 단위로 자르는 것을 기본으로 하지만, 개조식 문장이나 특수한 경우 적절히 조정한다. `<ttsSplit/>` 태그를 사용하여 TTS의 멈춤점을 명시한다. - 출력 형식 변환된 텍스트만 코드 스니펫으로 반환하며, 다음 예시에 준수한다. ``` "삼백오십이"라고 적혀 있었다. <ttsSplit/> "케이식스"는 다음으로 넘어간다. <ttsSplit/> "헤이치티티피"를 따라간다. <ttsSplit/> ``` 주의사항 - 문맥 고려: 입력 텍스트의 흐름과 의도를 해치지 않도록 자연스러운 문장을 작성한다. - 음성합성(TTS) 적합성: 발음 및 멈춤 처리의 품질을 최우선으로 한다. - 간결성: 불필요한 내용을 과도하게 추가하지 않는다. - 코드 및 괄호 제거: 비문맥적 요소는 제거한다. 위의 규칙과 형식에 맞춰 결과를 작성한다. 모든 작업 결과는 코드 스니펫 형태로 반환하도록 한다.
모델 변경 & 자동화하기
xTTS-v2 말고도 fishaudio의 fish-speech-1.5 모델도 테스트해보았다. 최대 2000 token의 긴 텍스트를 지원하고 품질도 더 좋은 것 같다. 따라서 모델을 fishaudio의 fish-speech-1.5로 변경하고, 팟캐스트 생성 및 업로드 작업을 자동화하였다. 위에서 언급한 대로 LLM을 이용해 먼저 텍스트 정규화를 진행하고, 텍스트를 나누어 순차적으로 합성한 뒤 병합하는 방식을 그대로 채택했다. 대신 모델을 fish-speech-1.5로 전환하여 더 자연스러운 결과물을 생성할 수 있게 되었다. 블로그 게시물 URL로 부터 제목과 본문 텍스트를 추출하고 정규화를 진행한 후, 음성을 합성한 뒤 병합하여 파일을 저장한다. RSS 피드에서는 최종 음성 파일이 저장된 폴더를 스캔해 목록을 반환하도록 했다. 파일들을 저장해둘 저장 공간과 상시 구동되어야 하며, Runtime에 시간 제한이 없어야 하기 때문에 Oracle Cloud VM에 배포하였다.
플레이어 UI (feat. 다이나믹 아일랜드)
그럼 이렇게 생성한 음성 파일을 각 게시글 페이지에서 재생할 수 있게 해줘야 한다. 위에서 만든 API가 반환해주는 RSS 피드에는 각 에피소드의 제목과 mp3 파일 주소 등이 포함되어 있다. 따라서 현재 게시글의 제목과 일치하는 제목을 가진 에피소드의 mp3 파일을 가져와 재생하는 방식으로 구현하였다.
해당하는 팟캐스트 파일을 찾으면, 제목 밑에 아래와 같이 재생바가 표시되어 음성을 재생할 수 있다.

아래로 스크롤하면 재생을 컨트롤할 수 있는 다이나믹 아일랜드 UI를 가진 컨트롤러가 뿅하고 튀어나온다. 다이나믹 아일랜드 구현체는 anaclumos/dynamic-island를 참고했다.
