ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • LLM 토큰을 듣자마자 TTS에 넣기 — 문장 경계 큐잉으로 첫 음성 지연 압축
    IT 2026. 5. 12. 23:00
    LLM 토큰을 듣자마자 TTS에 넣기 — 문장 경계 큐잉으로 첫 음성 지연 압축

    음성 챗봇의 첫 인상은 "질문을 끝낸 뒤 첫 음성이 들리기까지 몇 초 걸리느냐"로 결정됩니다. 5초가 넘으면 아이가 화면을 보며 "이거 고장 났어?"라고 물어봅니다. 3초 이하면 자연스러운 대화처럼 느낍니다.

    그래서 LLM 응답이 끝나기를 기다리지 않고, 토큰이 흐르는 동안 문장 단위로 TTS를 시작하는 패턴을 적용했습니다. 한국어 답변이 5문장이라면 첫 문장이 완성되는 1~2초쯤에 TTS 합성을 시작하고, 사용자는 LLM이 나머지 4문장을 만드는 동안 첫 문장을 듣고 있습니다. 첫 음성까지의 지연이 절반 이하로 줄어듭니다.

    기본 흐름과 단순한 접근의 한계

    LLM은 SSE(Server-Sent Events) 같은 스트리밍 채널로 토큰을 한 글자씩 흘려보냅니다. 그걸 받은 클라이언트가 화면에 그대로 찍으면 ChatGPT처럼 글자가 또르르 떠오르는 효과가 됩니다. 텍스트만 보여줄 때는 이걸로 충분합니다.

    음성을 추가할 때 가장 단순한 방법은 응답이 다 끝나길 기다렸다가 전체를 한 번에 TTS에 넘기는 것입니다. 코드도 짧고 음질도 일관됩니다. 그러나 첫 음성까지의 지연이 LLM 전체 응답 시간 + TTS 합성 시간이 됩니다.

    5문장 답변, 5초 LLM, 1초 TTS 합성이라면 첫 음성까지 6초가 걸립니다. 그동안 사용자는 그냥 기다립니다. 음성 UX에서 이건 너무 깁니다. 텍스트라면 글자가 흐르는 시각적 피드백이라도 있는데, 음성은 침묵이 곧 "고장 났나?"입니다.

    핵심 발상 — 문장 경계에서 즉시 큐잉

    해결의 발상은 단순합니다. LLM이 토큰을 흘려보내는 동안 클라이언트는 누적 텍스트를 살피다가, 문장 종결 부호(., !, ?, 한국어는 ··도) 가 나올 때마다 그 시점까지의 한 문장을 잘라내 TTS 큐에 넣습니다. TTS 큐는 별도 작업자가 순차로 꺼내 합성·재생합니다.

    단순 vs 문장 경계 큐잉 타임라인 비교

    그림 설명 — A 방식(단순)에서는 LLM이 5문장을 다 만드는 동안 사용자가 침묵을 듣다가 마지막에 한꺼번에 합성된 음성을 듣습니다. 첫 음성까지 6초. B 방식(문장 경계 큐잉)에서는 첫 문장이 완성되는 2초 시점에 TTS가 시작되고, 사용자는 LLM이 나머지 문장을 만드는 동안 이미 첫 음성을 듣고 있습니다. 첫 음성까지 2초로 1/3 수준. LLM과 TTS가 시간축에서 겹쳐 진행되는 게 핵심입니다.

    코드 — 두 함수면 끝난다

    구현은 의외로 단순합니다. 누적 텍스트와 마지막으로 처리한 위치를 추적하는 cursor 하나, 그리고 문장 종결 부호를 정규식으로 찾는 함수 하나입니다.

    // LLM 스트림에서 새 토큰이 들어올 때마다 호출
    async function streamChat(userText) {
      let fullText = '';
      let ttsCursor = 0;          // 마지막 TTS 큐잉 시점의 텍스트 길이
    
      const reader = resp.body.getReader();
      const decoder = new TextDecoder('utf-8');
    
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        // ... SSE chunk 파싱 ...
        if (typeof delta === 'string' && delta.length > 0) {
          fullText += delta;
          ttsCursor = flushSentence(fullText, ttsCursor);
        }
      }
    
      // 끝까지 못 짤린 잔여 텍스트는 한 번에 큐잉 (마침표 없이 끝나도 보장)
      if (ttsCursor < fullText.length) {
        enqueueTts(fullText.slice(ttsCursor).trim());
      }
    }
    

    코드 설명fullText는 LLM이 지금까지 흘려준 모든 토큰의 누적입니다. ttsCursor는 그 안에서 마지막으로 TTS 큐에 넣은 끝 위치를 가리킵니다. 매 토큰마다 flushSentence를 호출해 새로 들어온 부분에서 문장 종결을 찾고, 있으면 그 문장을 큐에 넣고 cursor를 업데이트합니다. 스트림이 끝났는데도 마침표 없이 끊긴 잔여 텍스트가 있으면 마지막에 한 번 더 enqueue해 빠짐없이 합성합니다.

    function flushSentence(full, cursor) {
      const tail = full.slice(cursor);
      const re = /([.!?。!?\n])/g;     // 문장 종결 부호들
      let lastEnd = -1;
      let m;
      while ((m = re.exec(tail)) !== null) lastEnd = m.index + 1;
      if (lastEnd > 0) {
        const chunk = tail.slice(0, lastEnd).trim();
        if (chunk) enqueueTts(chunk);
        return cursor + lastEnd;
      }
      return cursor;
    }
    

    코드 설명tail은 cursor 이후에 새로 들어온 부분입니다. 이 안에서 문장 종결 부호를 모두 찾아 가장 마지막 종결 위치를 기억합니다. 한 토큰 묶음에 여러 문장이 한꺼번에 들어올 수 있어서(특히 한국어 LLM이 종종 "예. 좋아요." 같이 짧은 두 문장을 한 번에 뱉음), 마지막 종결까지 한 번에 큐잉하는 편이 효율적입니다. 줄바꿈(\n)도 종결로 취급한 이유는 마침표 없이 한 단락 끝나는 케이스를 잡기 위함입니다.

    // TTS 큐와 재생 작업자 — 한 번에 한 문장씩 합성·재생
    
    // 합성 대기 중인 문장들이 쌓이는 FIFO 큐 (먼저 들어온 문장부터 재생)
    const ttsQueue = [];
    // 지금 재생 중인지 여부 — 두 음성이 동시에 겹쳐 나오지 않게 막는 자물쇠 역할
    let ttsActive = false;
    
    // [producer] flushSentence가 한 문장 잘라낼 때마다 호출
    function enqueueTts(text) {
      ttsQueue.push(text);             // 일단 큐 뒤에 쌓아두고
      // 작업자가 쉬고 있으면(=재생 중인 게 없으면) 깨워서 재생 사이클을 시작한다.
      // 이미 재생 중이라면 playNextTts의 onended 체인이 알아서 다음 문장을 꺼내가므로
      // 여기서는 아무것도 하지 않는다 — 중복 호출하면 두 음성이 겹쳐 재생됨.
      if (!ttsActive) playNextTts();
    }
    
    // [consumer] 큐에서 한 문장 꺼내 합성·재생하고, 끝나면 자기 자신을 다시 호출
    async function playNextTts() {
      // 더 꺼낼 문장이 없으면 사이클 종료. 다음 enqueueTts가 다시 깨워준다.
      if (ttsQueue.length === 0) {
        ttsActive = false;
        return;
      }
      const text = ttsQueue.shift();   // 가장 먼저 들어온 문장을 꺼내고
      ttsActive = true;                // "지금 재생 중" 표시 → enqueueTts의 중복 시작 방지
    
      // 서버에 텍스트 → 음성 합성 요청. speed 1.05는 살짝 빠르게(자연스러운 대화 속도).
      const r = await fetch('/api/tts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text, speed: 1.05 }),
      });
      const blob = await r.blob();                          // 합성된 음성 바이트(wav/mp3 등)
      const audio = new Audio(URL.createObjectURL(blob));   // 브라우저가 재생 가능한 객체로 변환
      // 재생이 끝나는 순간 다음 문장을 자동으로 시작 — onended가 사실상 큐 워커의 루프 역할.
      audio.onended = () => playNextTts();
      audio.play();
    }
    

    코드 설명 — TTS 큐는 단순한 배열이고, 한 번에 한 문장씩 꺼내 합성·재생합니다. 재생이 끝나면 onended가 다음 문장을 자동으로 시작합니다. 큐가 비면 ttsActive 플래그를 false로 두어 다음 enqueue가 와야 워크플로우를 다시 시작합니다. 동시에 두 합성이 돌지 않게 하는 자물쇠 역할도 같이 합니다.

    주의 — 문장이 너무 짧으면 끊김이 생긴다

    이 패턴이 항상 잘 동작하는 건 아닙니다. 첫 문장이 너무 짧으면 — 예를 들어 LLM이 "안녕!"이나 "네."로 시작하면 — TTS 합성이 0.5초 만에 끝나서 큐가 비고, 다음 문장이 아직 LLM에서 안 나왔다면 음성 사이에 침묵 간격이 생깁니다.

    이걸 줄이는 두 가지 길이 있습니다.

    1. 첫 짧은 문장은 합쳐서 큐잉한다. "네." + "다음 문장은 더 길어요."가 들어오면 두 개를 한 청크로 묶어 한 번에 합성. 자연스러운 흐름이 유지됩니다. 단점은 첫 음성까지의 지연이 약간 늘어남.
    2. 최소 문장 길이 임계값을 두기. 10자 미만의 문장은 다음 문장과 합쳐 보내기. 단점은 짧은 인사가 한 박자 늦어집니다.

    저는 현재 두 패턴 다 적용하지 않고 단순히 종결 부호 발견 즉시 enqueue하는 단순 방식을 쓰고 있습니다. 시스템 프롬프트에서 "한 문장으로 짧게 답하라"는 지시 대신 "음성으로 듣기 좋게 적당한 길이로 답하라"고 적어 LLM이 자연스럽게 길이를 조절하도록 했습니다. 이 정도면 짧은 문장 끊김 문제가 거의 안 일어납니다.

    스트림 인터럽트와 큐 비우기

    한 가지 더 신경 써야 할 점은 사용자가 답변 도중 마이크를 다시 누르는 경우입니다. 이때 진행 중인 LLM 응답·TTS 합성·음성 재생 모두 멈춰야 하고, 큐도 비워져야 합니다.

    function stopGeneration() {
      turnId += 1;                     // 현재 turn은 stale로 만든다
      if (chatAbortController) {
        try { chatAbortController.abort(); } catch {}
      }
      ttsQueue.length = 0;             // 큐 비우기
      if (ttsAudio) {
        try { ttsAudio.pause(); ttsAudio.src = ''; } catch {}
        ttsAudio = null;
      }
      ttsActive = false;
    }
    

    코드 설명 — 매 대화 turn마다 turnId를 증가시키고, 진행 중이던 작업은 자기 turnId가 현재값과 다르면 즉시 결과를 무시합니다. 이게 race condition 방지의 핵심입니다 — abort 호출이 SSE 스트림에 도달하기 전에 도착한 chunk나, TTS 합성이 끝나서 콜백되는 시점에는 이미 turn이 바뀐 상태일 수 있는데, turnId 비교로 그 stale callback을 자동으로 걸러냅니다. 큐도 길이 0으로 바로 비웁니다.

    마치며 — 직렬 시스템을 병렬로 다시 짜기

    LLM과 TTS는 본질적으로 직렬 — 텍스트가 만들어진 다음에 음성이 합성된다 — 이지만, 텍스트의 단위(문장)를 잘게 쪼개면 두 단계가 시간축에서 겹쳐 돕니다. 이게 이 패턴의 모든 효과입니다. 50줄 정도의 코드로 첫 음성까지의 지연이 1/3로 줄었습니다.

    이 발상은 음성 외에도 응용됩니다. LLM 응답을 다른 후처리에 넘기는 모든 파이프라인 — 요약, 번역, 코드 실행 — 에서 같은 패턴이 통합니다. 후처리가 문장·문단 단위로 동작 가능하면, 응답 전체를 기다리지 않고 부분이 완성되는 즉시 다음 단계로 넘기는 것이죠. 시스템 전체의 첫 결과까지의 지연이 직선적으로 줄어듭니다.

    음성 챗봇처럼 "기다림이 곧 신뢰 상실"인 환경에선 이 한 단계의 차이가 사용자가 챗봇을 신뢰할지 의심할지를 가릅니다. 아이가 "아빠 챗봇 자연스러워!"라고 한 날, 사실 그날 한 일은 이 50줄짜리 큐 코드를 쓴 것뿐이었습니다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.