배경
📣
개발 •  • 읽는데 12분 소요

Web Speech API로 프론트엔드에서 TTS 구현하기

음성 합성(Speech Synthesis) API를 활용하여 웹 브라우저에서 텍스트를 음성으로 변환하는 방법에 대해 알아봅니다.

#Front-End
#JavaScript


예시 블로그 본문 하단을 보면 확인할 수 있다

최근에 블로그에 새로운 기능을 하나 추가했습니다. 바로 음성으로 포스트를 읽어주는 TTS(Text to Speech) 기능입니다.

뜬금없이 웬 TTS냐고 생각하실 수도 있겠지만, 저는 사실 예전부터 TTS를 블로그에 추가하고 싶었습니다. 왜냐하면 저는 보통 작성한 포스트를 퇴고할 때, 눈으로만 읽는 게 아니라 직접 소리 내어 읽어 보기도 하고 TTS 도구를 이용해 음성으로 변환해 들어보기도 하거든요. 즉, TTS는 제가 블로그 포스트를 작성할 때 유용하게 사용하는 기능 이었습니다.

안드로이드 안드로이드 기기에서 구글 TTS를 쓰려면 읽을 부분을 드래그해서 선택해야 한다

기존에는 안드로이드 기기에서 제공되는 구글 TTS를 사용했는데 불편한 점이 꽤 있었습니다.

먼저 소리 내어 읽을 부분을 드래그해야 하는데, 본문이 길어질수록 드래그하기가 어렵습니다. 아래로 스크롤을 하다가 자칫 지금까지 선택한 영역이 풀리면… 이게 또 열받거든요. 그리고 본문 중간에 코드 블록이 포함된 경우가 꽤 있는데, 코드의 토큰 하나하나를 소리 내어 읽는다는 문제가 있었습니다. 마지막으로 음성이 나오는 동안에는 스크롤을 비롯한 다른 작업을 전혀 할 수가 없습니다.

이와 같은 불편한 점들 때문에 TTS를 블로그에 직접 구현하고 싶다는 생각이 들었는데요, 마침 JavaScript에서 음성을 다룰 수 있는 Web Speech API를 알게 되어 이를 활용해 구현했습니다.

그래서 오늘은 Web Speech API를 활용해 프론트엔드에서 TTS를 구현한 후기 에 대해 소개하려고 합니다. 이번 포스트를 통해 TTS 기능을 구현하시려는 개발자 분들께 도움이 되었으면 좋겠습니다.

구현하고 싶었던 기능

  • 본문을 드래그해서 선택하는 대신, 본문 전체를 음성으로 들을 수 있도록
  • 본문에 코드 블록이 있다면, 코드는 읽지 않게
  • 음성이 나오는 동안에도 스크롤이나 다른 작업을 할 수 있도록

Web Speech API

web speech api MDN 문서에 소개된 Web Speech API

오늘 소개할 기능의 핵심은 Web Speech API 입니다. Web Speech API는 음성 데이터를 웹 애플리케이션에서 쉽게 다룰 수 있도록 제공되는 API로, 크게 음성 합성(SpeechSynthesis) API와 음성 인식(SpeechRecognition) API로 구성되어 있습니다.

음성 합성은 TTS(Text to Speech) 라고도 불리며, OS에 내장된 음성 합성 및 인식 기능을 웹 브라우저에서 사용할 수 있게 도와줍니다. 음성 인식은 STT(Speech to Text) 라고도 불리며, 마이크를 통해 입력된 사용자의 음성을 텍스트로 변환하는 기능을 제공합니다.

우리가 구현하고자 하는 기능은 음성 합성 API이므로, 음성 합성 API에 대해 조금 더 알아보겠습니다.

Speech Synthesis API

caniuse 비표준이라는 점은 아쉽지만 브라우저 지원은 꽤 넓은 편이다

혹시라도 음성 합성 API에 대한 데모가 궁금하시다면, 여기에서 직접 테스트 해볼 수 있습니다.

음성 합성 API는 OS에 내장된 음성 합성 및 인식 기능을 웹 브라우저에서 사용할 수 있게 해주는 API입니다. 아직 비표준 API다 보니 브라우저마다 지원하는 기능이 각각 다르다는 점이 있지만, 생각보다 오래전부터 지원되는(?) 기능이라 이를 지원하는 브라우저 비율 자체는 높은 편입니다.

음성 합성 API 인터페이스에 접근하는 방법은 다음과 같습니다.

// 음성 합성 API 객체
window.speechSynthesis;

// 발화 객체 생성
const utter = new SpeechSynthesisUtterance('Hello, World!');

// 재생
window.speechSynthesis.speak(new SpeechSynthesisUtterance('Hello, World!'));
// 일시 중지
window.speechSynthesis.pause();
// 다시 재생
window.speechSynthesis.resume();
// 중지
window.speechSynthesis.cancel();

재생, 일시 중지, 다시 재생, 중지 등의 기본적인 기능을 제공하는 것을 확인할 수 있습니다.

재생할 음성을 만들기 위해서는 발화(utterance) 단위를 생성해야 하는데, 이는 SpeechSynthesisUtterance 객체를 생성하는 것으로 가능합니다. 그리고 이 객체를 speechSynthesis.speak() 메서드에 인자로 넘겨주면 음성이 재생됩니다.

발화 객체를 생성할 때는 음성의 속도, 음높이, 음량 등을 설정할 수 있습니다. 아래는 발화 객체를 생성하고 음성의 속도, 음높이, 음량을 설정하는 예시입니다.

const utter = new SpeechSynthesisUtterance('Hello, World!');
utter.rate = 1; // 음성 속도, 0.1 ~ 10 사이의 값
utter.pitch = 1; // 음높이, 0 ~ 2 사이의 값
utter.volume = 1; // 음량, 0 ~ 1 사이의 값
window.speechSynthesis.speak(utter);

만약 OS에서 여러 가지 음성 옵션을 제공한다면, speechSynthesis.getVoices() 메서드를 통해 사용 가능한 음성 목록 전체를 가져올 수도 있습니다.

const voices = window.speechSynthesis.getVoices();
const utter = new SpeechSynthesisUtterance('Hello, World!');

// en-US 음성을 찾아서 설정
utter.voice = voices.find((voice) => voice.lang === 'en-US');
console.log(utter.voice.name); // Aaron
window.speechSynthesis.speak(utter);

// ko-KR 음성을 찾아서 설정
utter.voice = voices.find((voice) => voice.lang === 'ko-KR');
console.log(utter.voice.name); // 유나
window.speechSynthesis.speak(utter);

speechSynthesis 객체는 내부적으로 SpeechSynthesisUtterance 객체를 큐에 담아서 순차적으로 음성을 재생합니다. 따라서 여러 개의 발화 객체를 생성하고 speak 메서드를 호출하면, 호출한 순서대로 음성이 재생됩니다. 비동기 함수가 아님에 주의해야 합니다.

const utter1 = new SpeechSynthesisUtterance('One');
const utter2 = new SpeechSynthesisUtterance('Two');
const utter2 = new SpeechSynthesisUtterance('Three');

window.speechSynthesis.speak(utter1);
window.speechSynthesis.speak(utter2);
window.speechSynthesis.speak(utter3);

// ['One', 'Two', 'Three'] 순서대로 음성이 재생됨

본문 추출하기

이렇게 해서 음성 합성 API의 간단한 사용법을 알아보습니다. 그런데 발화를 생성하기 위해서는 포스트 본문에서 읽을 텍스트만 추출해야 한다는 것을 알 수 있습니다. 즉, 아래와 같은 문법이 되어야 할 텐데요.

const post = `${블로그 전체 본문}`;
const utter = new SpeechSynthesisUtterance(post);
window.speechSynthesis.speak(utter);

다행이게도 제가 쓰고 있는 Gatsby 프레임워크에서는 페이지 단위에서 GraphQL을 이용해 마크다운 파일의 내용을 추출하는 기능을 제공합니다.

const PostTemplate = ({
  data,
}: PageProps<{ markdownRemark: MarkdownRemark }>) => {
  const { markdownRemark: post } = data;

  return <div>{post.rawMarkdownBody}</div>; // 마크다운 본문
};

export const pageQuery = graphql`
  query BlogPostBySlug($id: String!) {
    markdownRemark(id: { eq: $id }) {
      rawMarkdownBody
    }
  }
`;

쿼리 사진 쿼리 결과

즉 마크다운 문법으로 작성된 포스트의 본문을 얻는 것은 쉽게 가능합니다. 그런데 마크다운 문법을 그대로 음성으로 읽는다면 코드 블록이나 특수 문자들이 음성으로 재생되어 버릴 수 있습니다.

따라서 마크다운 문법을 제거하고 순수하게 발화할 텍스트만 추출하는 작업이 필요합니다.

// as-is
const rawMarkdownBody = `
## 제목

이것은 **본문**입니다.

\`\`\`js
console.log("Hello, World!"); // 이것은 코드 블록입니다.
\`\`\`

> 이것은 마크다운 문법입니다.

![이것은 이미지](./image.png)

이것은 [링크](https://example.com)입니다.
`;

// to-be
const onlyText = `제목\n이것은 본문입니다.\n이것은 마크다운 문법입니다.\n이것은 링크입니다.`;

이것은 정규식을 이용해 마크다운 문법을 제거하는 방법으로 가능합니다. 정규식은 어려우니까 AI(?)의 도움을 받아 작성했습니다. 100% 완벽하지는 않지만, 대부분의 경우에는 잘 작동합니다.

return `${rawMarkdownBody
  // 이미지 제거
  .replace(/!\[([^\]]+?)\]\([^)]+?\)/g, '')
  // 링크는 텍스트만 남기고 제거
  .replace(/\[([^\]]+?)\]\([^)]+?\)/g, '$1')
  // 코드 블록 제거
  .replace(/```[^\n]+?\n([\s\S]+?)\n```/g, '')
  // 불렛 제거
  .replace(/- ([^\n]+?)\n/g, '$1\n')
  // 특수문자 제거
  .replace(/([*_`~#>])/g, '')
  // 좌우 공백 제거
  .trim()}`;

콘솔 마크다운 본문 제거 로직을 브라우저 콘솔에서 실행시킨 결과

줄바꿈을 제외한 나머지 마크다운 문법을 제거하는 데 성공했습니다.

React 커스텀 훅 구현

마크다운 본문에서 발화 텍스트만 추출하는 것도 성공했으니, 음성 합성 API를 React에서 잘 활용할 수 있도록 커스텀 훅을 만들어 보았습니다. 사실 이미 만들어진 라이브러리를 찾아보긴 했는데, 비표준 API다 보니 레퍼런스도 적고 그냥 직접 만드는 게 나을 거 같아서 직접 만들었습니다.

코드가 너무 길어져서 상세 구현 내용은 생략하겠지만, 간단하게 구현한 커스텀 훅과 컴포넌트에서의 사용 예시는 아래와 같습니다. 커스텀 훅은 단순히 위에서 설명한 음성 합성 API 로직을 감싼 것밖에 없습니다.

const SpeechSynthesisController = ({ content }) => {
  const {
    speak,
    pause,
    cancel,
    resume,
    state, // 현재 음성 재생 상태, 'idle' | 'speaking' | 'paused'
  } = useSpeechSynthesis({
    content, // 음성으로 재생할 텍스트를 넘기면 훅 내부에서 `new SpeechSynthesisUtterance(content)` 를 State로 관리
  });

  return (
    <div>
      {(state === 'paused' || state === 'idle') && (
        <Tooltip title="재생" arrow>
          {state === 'paused' ? (
            <IconButton onClick={() => resume()}>
              <PlayCircleIcon />
            </IconButton>
          ) : (
            <IconButton onClick={() => speak()}>
              <PlayCircleIcon />
            </IconButton>
          )}
        </Tooltip>
      )}

      {state === 'speaking' && (
        <Tooltip title="일시정지" arrow>
          <IconButton onClick={() => pause()}>
            <PauseCircleIcon />
          </IconButton>
        </Tooltip>
      )}

      <Tooltip title="중지" arrow>
        <IconButton onClick={() => cancel()}>
          <StopCircleIcon />
        </IconButton>
      </Tooltip>
    </div>
  );
};

이렇게 해서 커스텀 훅까지 완성했고, 블로그에 TTS 기능을 추가할 준비가 되었습니다.

비표준 API를 쓰기 위한 험난한 길

비표준 비표준 API를 써? 넌 혼 좀 나자

자… 여기서부터가 하이라이트(?)이지 않을까 싶은데요. 이론 상으로는 완벽했지만 실제로 블로그에 적용하려고 하니까 여러 가지 문제가 발생했습니다. 브라우저 호환성 문제가 가장 심각했는데, 이것 때문에 별도의 라이브러리까지 있더라구요. 아래는 제가 리서치하면서 발견한 문제들입니다.

페이지를 이탈해도 계속 재생되는 음성

음성 합성 API는 JavaScript 문법을 이용해 OS에 내장된 기능을 활용하는 것인데요, 브라우저의 범위를 넘어서는 기능을 활용하기 때문인지 한 번 음성을 재생한 경우 다른 페이지나 다른 웹 사이트로 이동해도 음성이 계속 재생되는 문제가 있었습니다.

일단은 페이지 이탈의 경우는 beforeunload 이벤트를 통해 해결이 가능해서, useEffect 내에 이벤트 리스너를 부착하는 방식으로 해결했습니다.

// useSpeechSynthesis 훅 내부
useEffect(() => {
  window.addEventListener('beforeunload', cancel);

  return () => {
    window.removeEventListener('beforeunload', cancel);
  };
}, [utterances]);

하지만 클라이언트 단에서 라우팅을 처리하는 SPA(Single Page Application)에서는 이러한 처리만으로는 해결이 불가능했습니다. 왜냐하면 클라이언트 단에서 발생하는 페이지 이동은 JavaScript를 이용하는 것이기 때문에 beforeunload 이벤트가 발생하지 않기 때문입니다.

이 문제를 해결하기 위해서는 음성이 재생되는 컴포넌트가 언마운트될 때 speechSynthesis.cancel() 메서드를 호출해야 합니다. 그런데 React 함수 컴포넌트에서는 componentWillUnmount 와 같은 라이프 사이클 메서드가 없다보니 어떻게 해야 할지가 고민이었습니다.

그래서 일단은 React 애플리케이션을 전역으로 감싸는 컨텍스트(Context)에서 location.pathname 이 변경될 때마다 speechSynthesis.cancel() 메서드를 호출하는 방법으로 해결했습니다.

// 글로벌 Context API

// SSR 우회
const location =
  typeof window === 'undefined' ? { pathname: '' } : window.location;

// 현재 페이지 내에서 URL 변경 시 음성 중지
useEffect(() => {
  if (!window.speechSynthesis) {
    return;
  }

  if (!window.speechSynthesis.speaking) {
    return;
  }

  window.speechSynthesis.cancel();
}, [location.pathname]);

안드로이드에서는 pauseresume 이 사용 불가능

이렇게 구현이 잘 되었나 싶었는데… 이상하게 안드로이드 크롬에서는 pause 를 호출하면 모든 기능이 먹통이 되는 문제가 있었습니다. 이를 해결하기 위해서는 cancel 메서드를 호출한 후 다시 speak 메서드를 호출해야 하는데, 이는 사용자 경험에 큰 영향을 미치는 문제였습니다.

유독 이 환경에서만 동작이 안 하길래 검색해 봤는데, 스택 오버플로우에서는 이것이 크롬의 버그라고 하더라구요. 결국 이러한 브라우저 자체의 버그로 인해 일시 정지 기능은 만들어놓고 사용하지 못했습니다.

너무 긴 문장을 입력하면 음성이 나오지 않음

이것도 안드로이드 크롬에서 발생한 문제였는데요, 너무 긴 문장을 하나의 발화 객체로 만들면 음성이 나오지 않는 문제가 있었습니다.

const utter = new SpeechSynthesisUtterance('너무 긴 문장...');

// 안드로이드 크롬에서는 음성이 나오지 않음
window.speechSynthesis.speak(utter);

검색해 보니 구글 TTS는 한 번에 4096 바이트 이상의 문장을 읽지 못한다고 하더라구요. 그런데 제 포스트는 대부분 길이가 길기 때문에 이게 꽤나 큰 문제였는데요, 다행이게도(?) 긴 문장을 여러 개로 나누어서 음성을 재생한다면 이 문제를 해결할 수 있었습니다.

저는 마크다운 문법을 발화로 만들 것이기 때문에, 줄바꿈을 기준으로 문장을 나누어서 음성을 재생하도록 했습니다.

const utterances = content
  .split('\n')
  .map((line) => line.trim())
  .filter(Boolean)
  .map((line) => {
    return new SpeechSynthesisUtterance(line);
  });

utterances.forEach((utterance) => {
  window.speechSynthesis.speak(utterance);
});

사용자 인터렉션 없이는 음성 재생 불가

다행이게도 저에게는 해당이 되지 않는 문제였지만, 사용자 인터렉션 없이는 음성을 재생할 수 없다는 문제가 있었습니다. 이것은 크롬과 사파리 모두에서 동일하게 적용되는 정책이라고 하더라구요. 이와 관련해서도 연관 라이브러리, 스택 오버플로우 등에서 이야기가 나오고 있었습니다.

M1 맥북에서는 음성이 빠르게(?) 재생됨

희한한 문제가 하나 더 있었는데요, M1 맥북에서는 유독 음성이 빠르게 재생되는 문제가 있었습니다. 이건 하드웨어와 OS 레벨의 문제여서 해결이 불가능합니다.

웹뷰에서는 지원 안 됨

웹뷰에서는 지원이 안 되기 때문에 예외 처리를 꼭 해주어야 합니다. 다행이게도 저는 웹뷰 여부가 큰 문제가 되지 않았습니다.

// 기능이 제공되지 않는 경우를 항상 예외 처리
if (!window.speechSynthesis) {
  return;
}

모바일에서 브라우저 이탈 시 재생이 멈춤

모바일 브라우저에서 재생 버튼을 누른 후, 홈 버튼을 누르거나 화면을 잠그는 경우에 음성은 멈추지만 speechSynthesis API 상으로는 여전히 speaking 상태로 남아있는 문제가 있습니다. 이것도 뭐 하드웨어, OS와 연관된 문제라서 해결이 어려워 보이더라구요.

결론

dd 프로토타입 정도면 좋지만, 이것만 믿고 실제 서비스에 적용하기에는 문제가 너무 많다

우여곡절 끝에 TTS 기능을 블로그에 추가할 수는 있었지만, 이것만 믿고 실제로 서비스에 적용하기에는 아직 문제가 많은 API입니다.

이 API가 크롬 브라우저에 처음 이식된 것이 2014년임을 감안한다면, 제안된 지 10년이 넘은 API라는 것인데요, 그럼에도 불구하고 아직까지도 비표준 상태를 벗어나지 못하는 걸 보면… 여러모로 중요도가 낮은 것으로 판단하는 듯합니다.

특히 OS의 음성 합성 및 인식 기능을 웹 브라우저에서 사용하는 것이기 때문에, 프론트엔드 단에서 제어할 수 없는 문제가 많다는 것이 다소 아쉬웠습니다. 하지만 그럼에도 불구하고 저는 기존에 사용하던 구글 TTS보다는 훨씬 편리하게 음성 기능을 사용할 수 있어서 만족스럽습니다.

저는 개인 블로그라는 소박한(?) 공간에서 기능을 추가했기 때문에 이런 실험을 해볼 수 있었지만, 실제 상용 서비스에서 만약 이 기능을 사용하려고 한다면 뜯어 말리고 싶네요. 😇

이 포스트가 유익하셨다면?




프로필 사진

👨‍💻 정종윤

글 쓰는 것을 좋아하는 프론트엔드 개발자입니다. 온라인에서는 재그지그라는 닉네임으로 활동하고 있습니다.


Copyright © 2024, All right reserved.

Built with Gatsby