Code › tail-villain
TTS 이후의 음성 기능
음성 생성뿐 아니라 대기와 취소, 재생 상태까지 하나의 사용자 흐름으로 관리한 과정
처음 구현한 음성 기능은 speaker 버튼에서 시작했다.
텍스트 답변을 음성으로 읽어 주고 사용자가 마이크로 답하게 만들었지만, 실제 화면에서는 음성을 생성하는 API보다 사용자가 지금 무엇이 일어나고 있는지 알려 주는 상태가 더 필요했다.
브라우저의 Web Speech API를 쓰는 대신 Gemini 기반 STT와 TTS로 바꾸면서 품질, 비용, 실패 처리, latency를 제품 쪽에서 더 직접적으로 다룰 수 있었다. 대신 사용자는 더 많은 기다림을 만나게 됐기 때문에 앱이 듣고 있는지, 음성을 텍스트로 바꾸는 중인지, 답변 음성을 만드는 중인지가 보이지 않으면 화면은 멈춘 것으로 판단한다.
그래서 녹음 중에는 waveform을 보여 주고, TTS를 생성하는 동안에는 persona 이름 옆에 loading dots를 붙였다. 사용자가 녹음을 취소하면 STT 비용이 나가지 않도록 cancel path도 분리했다. STT가 끝난 뒤 textarea focus가 돌아오지 않는 문제는 React의 렌더 타이밍과 맞물려 있어서, 상태 전환을 본 뒤 effect에서 focus를 주도록 바꿨다.
voice UI에서는 소리가 나기 전의 침묵도 사용자가 제품 반응으로 읽기 때문에, 녹음과 생성 사이의 상태를 화면에 표시해야 했다.
성능 쪽에서는 TTS 출력 형식이 바로 걸렸다.
Gemini TTS는 브라우저에서 바로 쓰기 좋은 MP3를 주지 않았다. raw PCM이 내려와서 처음에는 WAV로 감싸 재생했고 동작 자체는 했지만, payload가 너무 컸다. 특히 모바일이나 느린 연결에서는 음성이 시작되기 전 대기 시간이 길어지고, voice 기능이 오히려 답답하게 느껴질 수 있었다.
Gemini 쪽에 MP3를 요청하는 설정도 시도했지만 해당 endpoint에서는 지원되지 않았다. 결국 압축은 서버에서 해야 했다. 몇 가지 encoder를 비교하다가 Node 환경에서 안정적으로 동작하고 속도도 괜찮은 WASM 기반 MP3 encoder로 바꿨다. sample rate는 24kHz로 유지했다. 16kHz로 낮추면 더 가벼워지지만, 실제로 들어 보면 전화 음질에 가까워져서 coaching 제품에는 맞지 않았다.
결론은 24kHz, 32kbps MP3였다. 아주 고급 오디오 품질을 목표로 한 선택은 아니었다. 답변을 또렷하게 들을 수 있고, 전송량을 크게 줄이며, 서버에서 변환하는 시간도 부담스럽지 않은 지점이었다. voice 기능에서 성능은 단지 숫자가 아니라, 사용자가 기다리는 동안 화면을 믿을 수 있는지와 연결된다.
마이크 버튼을 누른 뒤 getUserMedia가 장치를 잡는 동안 화면에 변화가 없으면 사용자는 클릭이 처리되지 않았다고 판단한다. 그래서 recording 상태와 별도로 mic acquiring 상태를 두고, 누르는 즉시 pulse 아이콘을 보여 주도록 했다. 실제 녹음이 시작되기 전의 짧은 틈도 UI 상태로 만들었다.
study mode에 voice를 붙일 때는 기능 노출 방식이 다시 문제가 됐다.
처음에는 TTS 설정을 persist하는 쪽으로 갈 수도 있었다. 하지만 그건 의도와 달랐다. Gemini TTS는 비용과 quota가 있는 기능이고, 매번 자동으로 켜지는 voice 경험은 너무 공격적이었다. 사용자가 이번 세션에서 정말 음성을 쓰겠다고 고른 경우에만 켜져야 했다.
몇 가지 디자인은 버렸다. “Start with Voice” 버튼은 voice를 너무 크게 밀어 주고, 별도 modal은 기존 study entry 흐름을 끊었기 때문이다. 결국 coach 선택 페이지의 기존 Start 버튼 아래에 작은 AI Voice checkbox를 넣는 방식으로 정리했고, 기본은 text로 둔 채 체크한 경우에만 시작 링크에 ?voice=1이 붙게 했다. 세션 페이지는 그 query를 읽어서 이번 진입에서만 TTS를 켠다.
이 방식은 voice를 기능의 중심으로 올려놓지 않는다. tail-villain의 기본은 여전히 대화와 압박 연습이고, 비용이 발생하는 음성 모드는 사용자가 명시적으로 선택한 세션에서만 활성화한다.
마지막으로 오디오 재생 채널을 하나로 합쳤다.
버블 하나의 speaker를 누르고, 다른 버블의 speaker를 누르면 두 소리가 겹칠 수 있었다. 원인은 재생 경로가 하나가 아니었기 때문이다. 캐시된 오디오 replay, streamSpeak queue, Gemini TTS strategy가 각자 따로 움직이고 있었고, 한쪽을 멈춰도 다른 쪽은 계속 재생될 수 있었다.
그래서 모든 재생 중단을 stopAllPlayback으로 모았다. 현재 재생 중인 버블을 다시 누르면 꺼지고, 다른 버블을 누르면 먼저 모든 채널을 멈춘 뒤 새 재생을 시작한다. opening voice도 loading에서 첫 sentence 재생으로 넘어갈 때 같은 speaker 상태를 쓰도록 연결했다. 생성된 오디오가 하나도 없을 때 loading dots가 계속 도는 edge case도 정리했다.
이건 화려한 기능은 아니지만, 음성이 들어간 순간 제품의 기본 예의가 된다. 사용자가 여러 오디오를 동시에 듣고 싶어 하는 경우는 거의 없으니 하나를 누르면 하나만 나와야 하고, 다음 것을 누르면 이전 것은 멈춰야 한다.
TTS는 voice 기능을 구성하는 한 부분이었다.
텍스트를 소리로 바꾸는 API 호출뿐 아니라 사용자가 명시적으로 켰는지, 앱이 듣고 있는지, 생성 중인지, 첫 문장이 언제 나오는지, 다른 소리가 멈췄는지까지 같은 흐름에서 관리해야 한다. 이 중 하나라도 어긋나면 재생 상태를 예측하기 어렵다.
결국 voice를 붙인다는 건 소리를 추가하는 일이 아니었다. 침묵, 대기, 취소, 재생, 중단까지 모두 제품 상태로 다루는 일이었다.