음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인
첫 소리를 당긴다 — 문장 나누기·겹쳐 합성·읽을 글
목표
TTS 의 RTF 와 첫 소리 지연을 재고, 문장·절 단위로 잘라 LLM 스트림과 겹쳐 합성하며, 화면용 글을 귀로 들을 글로 바꿔 알아듣기 쉬워지는지 왕복 WER 로 확인한다.
왜 중요한가
사용자가 기다리는 것은 답 전체가 아니라 첫 소리다. 첫 소리는 첫 조각의 길이와 합성 속도로 정해진다. 그리고 TTS 는 쓰인 대로 읽어서, 화면에는 멀쩡한 '14:30 [kb-hours]' 가 귀에는 엉뚱하게 들린다. 채점기는 합성한 WAV 를 샘플 단위로 비교하지 않습니다(VITS 는 매번 조금 다른 파형을 냅니다). 길이·RTF·사건 순서의 일관성을 보고, 알아들을 수 있는가는 여러분의 WAV 를 ASR 로 다시 받아 적어 확인합니다.
단계
- 한 문장을 합성해
/root/voice/tts/hello.wav로 저장하고, 합성 시간·길이·RTF 를/root/voice/tts/synth.json에 적으세요. - 약어와 소수에서 부서지지 않는
sentences(text)를/root/voice/tts/split.py에 만드세요. - 세 문장 글을 통째로 합성할 때와 첫 문장만 합성할 때의 시간을
/root/voice/tts/first_audio.json에 적으세요. - LLM 스트림을 조각으로 잘라 TTS 와 겹쳐 합성하고, 사건 시각을
/root/voice/tts/stream.json, 이어 붙인 소리를/root/voice/tts/stream_out.wav에 저장하세요. - 인용 표시·24시간 시각·긴 숫자·금액·약어를 읽을 글로 바꾸는
speakable(text)를/root/voice/tts/normalize.py에 만드세요. - 문장 셋을 원문 그대로와 읽을 글로 바꿔 각각 합성하고 ASR 로 받아 적어
/root/voice/tts/roundtrip.json에 적으세요. stream_out.wav를 16 kHz s16le 20 ms 프레임으로 바꿔/root/voice/tts/tts_stream.pcm과/root/voice/tts/tts_stream.json을 만드세요.- 측정을 모은
/root/voice/tts/report.json을 만드세요.
참고
- 모델:
from voicekit.models import tts→t = tts();a = t.generate(text, sid=0, speed=1.0); 소리는a.samples(float 목록), 샘플레이트는a.sample_rate(22050). - 첫 호출은 준비 시간이 섞여 느립니다. 재기 전에 짧은 글을 한 번 합성해 두세요.
- 리샘플링:
voicekit.resample_linear(x, sr_in, sr_out). ASR 에 넣을 때는 앞에 0.3초 무음을 붙이고 100 ms 조각으로 넣으세요 — TTS 소리는 첫 샘플부터 말이라 무음 없이 넣으면 첫 낱말을 놓칩니다(실측). - 흔한 실수: 모델 읽기 시간을 합성 시간에 넣기, 마침표마다 자르기, 22050 Hz 소리를 16000 이라고 적어 보내기.
- 문서: Piper voices(ljspeech 모델 카드) · VITS(Kim et al., 2021) · LJ Speech · sherpa-onnx
한 문장을 합성하고 RTF 를 잰다
모델을 만들고 짧은 글로 한 번 데운 뒤 'Your appointment is on Tuesday at ten in the morning.' 를 합성해 /root/voice/tts/hello.wav(22050 Hz 모노)에 저장하고, /root/voice/tts/synth.json 에 sample_rate·synth_ms(generate 에 걸린 시간)·audio_s·rtf(synth_ms/1000/audio_s)를 적으세요.
time.perf_counter() 로 generate 호출만 감쌉니다. RTF 가 1보다 작으면 말하는 속도보다 빨리 만듭니다. 모델 읽기(1~2초)를 넣으면 RTF 가 1을 넘어 보입니다.
부서지지 않는 문장 나누기
/root/voice/tts/split.py 에 sentences(text) 를 만드세요. 공백을 하나로 접은 뒤, .!? 뒤에 공백과 대문자(또는 글 끝)가 올 때만 자르되 'Dr.'·'Mr.'·'Mrs.'·'Ms.'·'St.'·'No.' 뒤에서는 자르지 않습니다. 조각을 이으면 원문과 같아야 하고, 빈 글은 빈 목록입니다. 채점기가 숨긴 사례로 시험합니다.
re.finditer(r".!?", text) 로 후보를 찾고, 후보 직전 낱말이 약어 목록에 있으면 건너뜁니다. '$3.50' 은 마침표 뒤가 숫자라 후보가 되지 않습니다.
통째로 vs 문장 단위 — 첫 소리까지
'Please arrive fifteen minutes before your visit. Bring a photo ID and your insurance card. If you need to cancel, call at least one day before.' 를 통째로 합성하는 시간(whole_ms)과 split.py 로 나눈 첫 문장만 합성하는 시간(chunked_ms), 문장 수(sentences)를 /root/voice/tts/first_audio.json 에 적으세요.
통째로면 전부 만들어야 첫 소리를 내보낼 수 있고, 문장 단위면 첫 문장만 만들면 됩니다. 둘 다 데운 모델로 잽니다.
LLM 과 겹쳐 합성한다
voice-llm up 뒤, 'You are a clinic phone assistant. Answer in two or three short sentences.' 와 'How do I prepare for a fasting blood test?' 를 voicekit.llm.chat_stream(msgs, max_tokens=80) 으로 받으며, 글이 쌓일 때마다 앞 조각을 잘라(문장이 끝났으면 문장, 아니면 다섯 낱말 넘는 쉼표 앞 절, 그것도 아니면 열두 낱말) 큐로 TTS 스레드에 넘기세요. 사건 llm_first_token·chunk(넘긴 조각, text 포함)·audio(합성이 끝난 조각, audio_s 포함)·llm_done 을 한 시계로 /root/voice/tts/stream.json 에 적고, 소리를 순서대로 이어 /root/voice/tts/stream_out.wav(22050 Hz)에 저장하세요.
TTS 는 threading.Thread 로 따로 돌리고 queue.Queue 로 조각을 받습니다. 끝에 None 을 넣어 스레드를 끝내고 join 합니다. 조각이 둘 이상이어야 겹침이 생깁니다 — 작은 모델이 한 문장으로 답해도 쉼표·낱말 수로 자르면 됩니다.
화면용 글을 귀로 들을 글로
/root/voice/tts/normalize.py 에 speakable(text) 를 만드세요. 인용 표시 [kb-…] 를 지우고, 'H:MM a.m./p.m.' 과 24시간 'HH:MM' 을 말로(14:30 → two thirty p m, 09:00 → nine o'clock a m), 세 자리 이상 숫자는 한 자리씩(911 → nine one one), '$80' 은 eighty dollars, 나머지 한두 자리 숫자는 낱말로, 'Dr.' 는 Doctor, 'a.m.'·'p.m.' 은 'a m'·'p m' 으로 바꿉니다. 채점기가 숨긴 사례로 시험합니다.
순서가 중요합니다. 시각(9:30)을 먼저 바꿔야 숫자 규칙이 9 와 30 을 따로 읽지 않습니다. 0~99 를 말로 바꾸는 작은 함수 하나면 시각·금액·숫자에 모두 씁니다.
TTS → ASR 왕복으로 알아듣기를 잰다
'Your visit is at 14:30 [kb-hours].' · 'Call 911 right away.' · 'A visit costs $80.' 를 각각 원문 그대로(raw)와 speakable 로 바꿔(norm) 합성하고, 16 kHz 로 바꿔 앞에 0.3초 무음을 붙여 /root/voice/tts/rt{i}_{kind}.wav(i 는 1~3)로 저장한 뒤 스트리밍 ASR 에 100 ms 씩 넣어 받아 적으세요. /root/voice/tts/roundtrip.json 에 여섯 줄 {"id", "kind", "tts_input", "heard", "meant"}(meant 는 speakable(원문))를 적으세요.
사람 귀 대신 ASR 을 듣는 사람으로 씁니다. 채점기는 여러분의 WAV 를 다시 받아 적어 meant 와의 WER 을 raw·norm 으로 비교합니다. 조각 크기가 다르면 스트리밍 결과가 조금 달라질 수 있어 100 ms 로 맞춥니다.
전송 형식으로 바꾼다
stream_out.wav(22050 Hz)를 voicekit.resample_linear 로 16 kHz 로 바꿔 s16le 20 ms(320 샘플) 프레임으로 이은 /root/voice/tts/tts_stream.pcm(마지막 프레임은 0 으로 채움)과, format·sample_rate·channels·frame_ms·frame_bytes·frames 를 적은 /root/voice/tts/tts_stream.json 을 만드세요.
모듈 1 의 마지막 단계와 같은 모양입니다. 22050 을 그대로 16000 이라고 보내면 1.38배 느리고 낮은 소리가 됩니다.
보고서
/root/voice/tts/report.json 에 rtf(synth.json), whole_ms·chunked_ms(first_audio.json), stream.json 에서 사건별 처음 시각인 llm_first_token_ms·first_audio_ms(첫 audio)·llm_done_ms 를 적으세요.
사건 목록을 앞에서부터 읽으며 이름마다 처음 본 시각만 남깁니다(dict.setdefault).