Dev/졸프

React Google translate + tts API 로 텍스트 번역, 읽기

rryu09 2023. 11. 14. 23:29

졸업 프로젝트로 부분해설을 지원해주는 도슨트 어플리케이션을 제작하고 있습니다.

도슨트 어플리케이션이라고 하면.. 해설 정보를 번역하고 오디오 해설을 들을 수 있도록 해주어야겠지요.

웹앱으로 제작하고 있으니 react와 google cloud API를 이용해 구현하는 방법을 알아볼까요?

 

목차

이번 글은 다음과 같은 순서로 이루어져 있어요.

  1. 구현해야 할 사항 파악하기
  2. 서버에서 해설 데이터 받아오기, react-query를 이용해 받아온 데이터 캐싱
  3. translate API를 이용해 캐싱한 해설 번역해 표시
  4. text-to-speech API를 이용해 해설 음성 데이터로 받아오기
  5. Audio 컴포넌트 제작, 일시정지/배속 등 기능 구현
  6. 결과 확인! 👍

 

전체적인 흐름을 그림으로 표시하면 다음처럼 될 거예요.


1. 구현해야 할 사항 파악하기

먼서 부분 해설이 들어가야 하는 페이지를 살펴볼까요?

가장 위쪽 퍼즐 아이콘 및에 들어갈 제목, 이미지, 핀 포인트, 핀 포인트의 개수와 해결된 핀 포인트의 수, 해설의 제목과 본문, 오디오가 들어가면 되겠네요!

많은 내용이 있지만 이 글에서는 해설의 제목과 본문, 오디오 부분만 살펴볼게요.

 


2. 서버에서 해설 데이터 받아오기, react-query를 이용해 받아온 데이터 캐싱

화면에 해설을 보여주려면 해설 데이터가 있어야겠죠?

다행히도 우리 졸프 백엔드 팀이 해설 데이터에 접근할 수 있는 api를 만들어 주었어요. 😚

외부에서 정보를 얻어온다면 그에 맞게 url을 고쳐주면 됩니다.

제공하려는 정보를 전부 프론트단에 가지고 있다면 이 단계는 건너뛰어도 괜찮아요.

 

캐싱 X

캐싱을 하지 않는다면 페이지 컴포넌트 상단에 useEffect를 사용해 페이지에 접근할 때마다 정보를 불러올 수 있어요.

  useEffect(() => {
    serverLoggedAxios
      .get(`<접근주소>`)
      .then((res) => {
        // do smt with res
      })
      .catch((err) => {
        console.log(err);
      });
  }, []);
const serverLoggedAxios = axios.create({
  baseURL: process.env.REACT_APP_SERVER_URL,
  headers: {
    "Content-Type": "application/json",
  },
});

serverLoggedAxios는 axios.create를 이용해 만든 Instance예요. 우리 서버는 요청을 할 때 헤더에 토큰을 붙여야 해서, interceptors를 사용해 헤더에 토큰을 붙여 요청을 전송하고, token 만료시 refresh 요청을 하고 재요청하는 로직을 미리 붙여 두었어요.

instance 를 만들지 않았다면 기본 axios를 이용해 config를 자세히 해 주면 된답니다.

 

캐싱 O

하지만 해설은 잘 바뀌지 않는 정보인데, 페이지에 접근할 때마다 서버에 정보를 요청하면 네트워크도 무거워지고 서버에 부담도 커지겠죠?🥲

일정 시간동안 한번 받아온 해설 정보를 프론트에서 저장한다면 훨씬 효율적일 거예요.

 

https://www.npmjs.com/package/react-query

 

react-query

Hooks for managing, caching and syncing asynchronous and remote data in React. Latest version: 3.39.3, last published: 10 months ago. Start using react-query in your project by running `npm i react-query`. There are 1351 other projects in the npm registry

www.npmjs.com

https://github.com/TanStack/query (깃허브)

 

GitHub - TanStack/query: 🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/J

🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query. - GitHub - TanStack/query: 🤖 Powerful as...

github.com

캐싱을 하기 위해 react-query를 이용합니다. 사용하기 앞서 해당 문서를 쭉 읽어보고 사용하기를 추천해요!

 

npm i react-query

다음 코드로 react-query 패키지를 프로젝트에 설치할 수 있어요.

 

import { useQuery } from '@tanstack/react-query'

function App() {
  const info = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList })
}

공식 문서에 다음과 같이 사용하면 된다고 나와있네요! 가장 기본적인 캐싱을 해볼게요.

 

  const docentDetailPageInfo = useQuery(
    `docentDetailPageInfo_${artId}_${detailId}`,
    () =>
      serverLoggedAxios.get(`/<요청주소>/${artId}/${detailId}`),
    {
      staleTime: <지정시간>,
      cacheTime: <지정시간>,
      enabled: true
    }
  );

useQuery를 이용해 캐싱한 정보를 docentDetailPageInfo라는 변수에 저장합니다.

useQuery의 첫번째 파라미터에는 유일한 queryKey를, 두번째 파라미터는 콜백 함수를 담았어요. 콜백 함수를 통해서 서버에 요청을 보내고 데이터를 받아오는 것이죠.

제 경우 요청 주소와 데이터가 artId와 detailId에 따라 달라져서, 요청 주소와 queryKey 안에 변수를 넣어 다양한 페이지에 유동적으로 대응할 수 있도록 설정했어요.

 

이제 페이지에서 나갔다가 들어와도 이미 캐싱된 정보가 있다면 다시 요청을 보내고 데이터를 받아오지 않아요!

로딩 속도도 훨씬 빠르고 서버 부담도 덜하겠네요!


3. translate API를 이용해 캐싱한 해설 번역해 표시

가지고 있는 해설 데이터는 한국어인데, 사용자가 언어 설정을 다르게 해서 다른 언어로 보여주고 싶다면 어떻게 하면 될까요?

먼저 사용자의 언어를 감지하기 위해 i18next를 사용해 볼게요.

UI의 경우 다음과 같이 i18n.js에 언어별 번역본을 작성해 유저의 언어에 대응해 표시되도록 설정했어요.

 

https://www.npmjs.com/package/i18next

 

i18next

i18next internationalization framework. Latest version: 23.7.6, last published: 20 hours ago. Start using i18next in your project by running `npm i i18next`. There are 5416 other projects in the npm registry using i18next.

www.npmjs.com

i18n.language

다음 코드로 현재 설정된 유저의 언어를 알 수 있습니다.

사용자의 언어를 알아보고 한국어이면 그대로 보여주고, 한국어가 아니라면 지정된 언어로 번역한 결과를 보여주어야겠지요.

google cloud의 translate API를 이용해 국문 텍스트를 기타 언어로 번역할 수 있어요.

 

https://cloud.google.com/translate/docs/reference/api-overview

 

API usage overview  |  Cloud Translation  |  Google Cloud

Send feedback Stay organized with collections Save and categorize content based on your preferences. API usage overview This guide provides an overview of using the Cloud Translation API and its reference documentation. Client libraries, REST, and gRPC You

cloud.google.com

자세한 내용은 위 문서를 참고해 본인의 프로젝트에 맞는 방식으로 적용하기 바랍니다.

많은 양의 텍스트 번역을 원한다면 비용이 꽤 들 수가 있는데요, 신규라면 현재 300달러 정도의 크레딧을 무료로 제공하고 있는 것 같으니 잘 확인해보고 사용하는 게 좋을 것 같네요.

 

저는 REST API 형식으로 사용했는데요, 

페이지에 들어갈 때마다 번역 API를 호출한다면 비용이 많이 들기 때문에 번역된 해설도 캐싱했습니다.

  const translatedContentData = useQuery(
    [`translation_content_${artId}_${detailId}`],
    () =>
      translate(
        docentDetailPageInfo.partDescription,
        i18n.language
      ),
    {
      staleTime: <지정시간>,
      cacheTime: <지정시간>,
      enabled: translateEnable,
    }
  );

translate()는 아래와 같습니다.

import { translateAxios } from ".";

export const translate = async (q, lng) => {
  const tData = JSON.stringify({
    q: q,
    target: lng,
  });
  try {
    const response = await translateAxios.post("", tData);
    return JSON.stringify(response.data.translations[0].translatedText)
      .replace(/"/g, "")
      .replace(/&#39;s/g, "'")
      .replace(/&#39;/g, "'")
      .replace(/&lt;/g, "[")
      .replace(/&gt;/g, "]");
  } catch (err) {
    console.log(err);
  }
};

translateAxios 역시 axios Instance이고, baseUrl로 google translate API의 endpoint를 입력해뒀습니다. axios instance에서 따로 설정할 것이 크게 없으면 Instance를 만들지 않고 진행하는 게 더 깔끔할 것 같네요.

translate API가 요구하는 형식으로 data를 정제하고, 답변을 받아오는 과정입니다.

저는 이후 tts API 적용 후 <, >와 같은 기호를 음성으로 읽어주는 것을 원치 않아 replace()를 이용해 기호를 바꾸는 과정을 거쳤습니다.

 

이때 이미 캐싱된 데이터를 또다시 캐싱하는 것이기 때문에, query chaining 관리를 잘 해주어야 합니다.

 

query chaining은 여러 쿼리 간에 의존성을 관리하고 효율적으로 데이터를 가져오는 방법을 나타냅니다.

한 쿼리의 결과에 따라 다른 쿼리를 트리거하고 싶을 때 query chaining을 사용하면 유용하겠네요.

  useEffect(() => {
    if (i18next.language !== "ko" && docentDetailPageInfo.isFetched) {
      setTranslateEnable(true);
    } else {
      setTranslateEnable(false);
    }
  }, [i18next.language, docentDetailPageInfo.status]);

다음과 같이 useEffect를 이용해 translateEnable state를 설정하고, translatedContentData의 enabled를 해당 state로 설정해 두었습니다. 그러면 한국어가 아니고 docentDetailPageInfo가 fetch 되었을 때만 translateEnable 이 true가 되어 번역이 일어나게 되겠죠?

 

이와 같이 enabled 속성을 건드리는 방법이 있고, 

import { useQuery } from 'react-query';

const ParentComponent = () => {
  const { data: parentData } = useQuery('parentQueryKey', fetchData);

  const { data: childData } = useQuery(
    'childQueryKey',
    fetchChildData,
    {
      enabled: false, // 기본적으로 비활성화
      onSuccess: () => {
        // 부모 쿼리가 성공하면 이곳에서 자식 쿼리를 활성화
        queryClient.invalidateQueries('childQueryKey');
      },
    }
  );

  // 나머지 컴포넌트 렌더링 및 로직
};

위처럼 useQueryonSuccess 콜백을 사용하는 방법도 있습니다.

 

쿼리 체이닝을 통해 원하는 정보의 캐싱을 마쳤다면 화면에 이 정보를 표시할 차례네요!

화면에 사용자 언어가 한국어일 때는 국문 해설을 표시하고, 기타 언어일 때는 번역된 해설을 표시하려면 return 문 안의 JSX에 다음과 같이 조건문을 사용하면 되겠죠?

              <typo.body.DocentContent>
                {i18n.language !== "ko"
                  ? translatedContentData.data
                  : docentDetailPageInfo.partDescription}
              </typo.body.DocentContent>

 

 

 

이제 translate API를 이용해 캐싱한 해설을 번역해 표시했어요!


4. text-to-speech API를 이용해 해설 음성 데이터로 받아오기

텍스트 정보는 완성이 되었으니 이 텍스트 정보를 tts API를 통해 음성으로 들어볼까요?

 

https://cloud.google.com/text-to-speech?hl=ko

 

Text-to-Speech AI: 생동감 있는 음성 합성 | Google Cloud

Google의 머신러닝 기술에 기반한 API를 통해 40개가 넘는 언어 및 방언을 지원하는 220여 개의 자연스러운 음성으로 텍스트를 변환합니다.

cloud.google.com

해당 웹페이지에서 데모를 통해 원하는 Voice type과 Voice name, Audio device profile 등을 미리 보고 적용해볼 수 있어요. 서비스에 가장 잘 맞는 조합을 살펴보고 정해 두면 좋겠네요.

이제 저 플레이 버튼을 누르면 밑의 텍스트가 음성으로 들리게 만들어볼 거예요.

                <TouchArea
                  onClick={async () => {
                    if (
                      audioId !== docentDetailPageInfo.relicId
                    ) {
                      // google tts
                      await ttsTransform(ttsConfig()).then((res) => {
                        console.log("ttsTransform: ", res);
                        // 부분 id로 설정 (현재 재생 중인 소스 식별)
                        setAudioId(docentDetailPageInfo.relicId);
                        setAudioData({ data: res.data.audioContent });
                      });
                    }
                    handleAudioPlay();
                  }}
                >
                  {isAudioPlaying &&
                  audioId === docentDetailPageInfo.relicId ? (
                    <PauseIco />
                  ) : (
                    <PlayIco />
                  )}
                </TouchArea>

플레이 버튼의 onClick에 큰 콜백함수를 넣어줬어요. 재활용 & 가독성을 위해 분리해서 따로 함수로 만드는 게 나을 것 같네요.

state로 audioId를 저장해 두어서 마지막으로 받아온 음성이 해당 텍스트가 아닌 경우에만 tts API를 실행하도록 했어요.

 

isAudioPlaying은 해당 오디오가 재생되고 있는지를 담아둔 state인데, 재생 중이면 멈춤 아이콘을 보여주고 재생 중이 아니면 재생 아이콘이 보이도록 만들었어요.

 

ttsTransform에 들어가는 ttsConfig()는 뭘까요?

ttsConfig

  function ttsConfig() {
    // tts
    let content, voicelngCode, voiceName;
    if (i18n.language === "ko") {
      content = docentDetailPageInfo.partDescription;
      voicelngCode = "ko-KR";
      voiceName = "ko-KR-Neural2-B";
    } else if (i18n.language === "en") {
      content = translatedContentData.data;
      voicelngCode = "en-US";
      voiceName = "en-US-Neural2-F";
    }
    const ttsData = {
      input: {
        text: content,
      },
      voice: {
        languageCode: voicelngCode,
        name: voiceName,
      },
      audioConfig: {
        audioEncoding: "MP3",
        effectsProfileId: ["small-bluetooth-speaker-class-device"],
        pitch: 0,
        speakingRate: 1,
      },
    };
    return ttsData;
  }

ttsConfig의 내용은 다음과 같습니다. 아까 정했던 voiceName 등의 config를 만드는 과정이에요. 언어를 일단 한국어와 영어로만 지정해 두었는데, else if 부분을 변수로 지정하면 다국어 tts 설정도 가능해요. config를 마친 ttsData를 반환하네요. 그러면 ttsTransform이 해당 데이터를 가지고 처리할 수 있게 되었네요!

 

ttsTransform

export const ttsTransform = async(data, config) => {
    try {
        const response = await ttsAxios.post('/v1/text:synthesize', data, config)
        return response
    } catch (error) {
        console.log(error)
    }
}

ttsTransform 은 이렇게 생겼어요. 아까 translate 해오는 과정과 비슷하죠?

ttsAxios는 axios instance로, tts API의 baseUrl과 제 구글 클라우드 API 키가 들어있어요. 이 API키가 있어야 api를 이용할 수 있어요.

 

tts API를 통해 정보를 보내고 가면 결과로 base64형태의 음성 데이터를 받을 수 있어요.

setAudioData({ data: res.data.audioContent });

해당 데이터를 audioData에 할당했어요.

  useEffect(() => {
    if (audioData) {
      audio.pause();
      setAudio(new Audio("data:audio/wav;base64," + audioData.data));
      setIsAudioPlaying(true);
    }
  }, [audioData]);

다음과 같은 audioData가 바뀔 때마다 실행되는 useEffect 구문이 있어서, audioData가 있으면 현재 재생되고 있는 audio state 안 Audio 객체를 pause하고 새 데이터를 가진 새로운 오디오 객체를 만들어요. 그리고 재생 중인지를 감지하기 위한 isAudioPlaying state도 true로 바꿔줍니다. 그러면 audio state 안에 이번에 받아온 데이터를 가진 Audio 객체가 들어가 있겠네요!

 

handleAudioPlay

  function handleAudioPlay() {
    if (isAudioPlaying && audio.src !== "") {
      audio.pause();
      setIsAudioPlaying(false);
    } else if (audio.src !== "") {
      audio.play();
      setIsAudioPlaying(true);
    } else {
      console.log("no audio");
    }
  }

이제 드디어 재생을 해볼까요?

else if 구문부터 먼저 보면, audio의 src가 있다면 재생하라고 되어 있네요. 그리고 isAudioPlaying을 true로 만들어줍니다.

 

if 문 안의 내용은 뭘까요? 바로 재생되고 있는 중에 버튼을 누르면 재생되고 있는 음성을 멈추게 하는 기능입니다.

isAudioPlaying이 true이고 audio의 src가 있다면 일단 멈추고 isAudioPlaying을 false로 만듭니다.

 

else는 현재 audio.src가 없는 경우 혹은 기타 경우인데요, 이 경우 콘솔에 오디오가 없다고 표시합니다. 

 

그러면 이제 텍스트 데이터를 음성으로 재생할 수 있게 된 걸까요?

 


5. Audio 컴포넌트 제작, 일시정지 / 배속 등 기능 구현

하지만 안타깝게도 Audio 객체를 다루는 컴포넌트를 만들어주어야 해요.

그래야 서비스를 사용하는 유저들이 간편하게 음성을 듣고 정지하고 배속도 할 수 있겠죠!

          {/* 하단 오디오 탭 */}
          {audioData ? (
            // audio 존재하는 경우에만 AudioBtn 표시
            <AudioBtn
              setPlaybackSpeed={setPlaybackSpeed}
              playbackSpeed={playbackSpeed}
              isPlaying={isAudioPlaying}
              setIsAudioPlaying={setIsAudioPlaying}
              handleAudioPlay={handleAudioPlay}
              audio={audio}
            />
          ) : null}

저는 위와 같이 audioData가 있을 경우에만 AudioBtn 컴포넌트를 표시하도록 했어요.

props로 너무 많은 정보가 있으니 객체로 만들어서 묶어서 전달하는 편이 낫겠네요.

 

AudioBtn 컴포넌트를 볼까요?

AudioBtn

    <Container>
      {speedSetOpen ? (
          <AudioButtonSet />
      ) : null}
      <Background>
        <TouchArea
          onClick={() => {
            setIsAudioPlaying(!isPlaying);
            handleAudioPlay();
          }}
        >
          {isPlaying ? <PauseIco /> : <PlayIco />}
        </TouchArea>
        <typo.body.Body03>
          {audioTime[0]} / {audioTime[1]}
        </typo.body.Body03>
        <ProgressBarBG>
          <ProgressBar current={(audioTime[0] / audioTime[1]) * 100} />
        </ProgressBarBG>

        <TouchArea
          onClick={() => {
            setSpeedSetOpen(!speedSetOpen);
          }}
        >
          <PlaySpeedIco fill={colors.brown} />
        </TouchArea>
      </Background>
    </Container>

맨 오른쪽 배속 버튼을 누르면 옵션들이 펼쳐지며 위쪽으로 나와야 하니, speedSetOpen이라는 state를 만들고 이 부분을 관리해요. 다시 누르면 없어져야 하니 onClick을 했을 때 !speedSetOpen으로 값을 지정하면 토글처럼 기능하겠네요!

speedSetOpen이 true이면 AudioButtonSet이 표시되고, false면 null이 표시되어 아무것도 보이지 않습니다. 

 

            <AudioSpeedBtn
              btnSpeed={0.5}
              playbackSpeed={playbackSpeed}
              setPlaybackSpeed={setPlaybackSpeed}
            />

AudioButtonSet 안 버튼 하나는 이렇게 생겼어요. 이 버튼을 누르면 속도가 0.5배로 변하게 설정했습니다.

 

재생/멈춤 버튼을 누르면 아까 만들어 두었던 handleAudioPlay를 연결해 재생하거나 멈출 수 있도록 만들었어요.

이제 배속 설정과 멈춤/재생은 구현되었네요!

 

기타 부분들은 프로그레스바를 만들고 남은 시간들을 표시한 부분들이에요.

 


6.  결과 확인하기!

거의 다 만들었네요!

다른 곳에서도 이 기능을 사용할 거라면 최대한 기능과 컴포넌트를 분리해 폴더에 정리해 두고 재활용하는 게 좋아요. 그렇게 하면 중복된 코드를 줄일 수 있고 에러가 생겨도 해당 부분만 수정하면 고칠 수 있어요.

 

 

화이팅!

 

'Dev > 졸프' 카테고리의 다른 글

Custom object detection(YOLOv5)  (3) 2023.05.26
RN Platform adapter testing  (0) 2023.05.07