Node.js와 React에서 VITO 음성인식 API 사용하기 (with TypeScript) - 파일 STT

VITO의 STT API는 다양한 언어에서 쉽게 접근하고 활용할 수 있는 특징을 가지고 있습니다. 공식 문서에서는 주로 Java, Curl, Python 등을 통해 API의 사용 방법을 안내하고 있습니다. 하지만 이번에는 조금 다른 방법으로, Typescript를 이용하여 React.js와 Node.js에서 일반 STT API를 적용해보는 튜토리얼을 준비했습니다.

Node.js와 React에서 VITO 음성인식 API 사용하기 (with TypeScript) - 파일 STT

by Luna

VITO의 STT API는 다양한 언어에서 쉽게 접근하고 활용할 수 있는 특징을 가지고 있습니다. 공식 문서에서는 주로 Java, Curl, Python 등을 통해 API의 사용 방법을 안내하고 있습니다. 하지만 이번에는 조금 다른 방법으로, TypeScript를 이용하여 React.js와 Node.js에서 파일 STT API를 적용해보는 튜토리얼을 준비했습니다.

TypeScript는 JavaScript의 슈퍼셋으로, 큰 규모의 어플리케이션 개발을 위해 탄생했습니다. 이번 튜토리얼을 통해 TypeScript와 React 환경에서 VITO의 파일 STT API를 어떻게 적용할 수 있는지 함께 알아보겠습니다.

일반 STT | VITO STT OpenAPI
일반 STT API는 음성 파일을 텍스트로 변환할 수 있는 API입니다. 일반 STT API의 경우, HTTP 기반의 REST API로 구현되어 있습니다.
💡
[파일STT API]
- 음성 파일 포맷 mp4, m4a, mp3, amr, flac, wav 을 지원
- 최대 인식파일 크기: 2GB, 최대 인식가능 시간: 4시간.

1. 프로젝트 생성 및 패키지 설치

React

# 프로젝트 생성

npm install --global yarn vite
yarn create vite [프로젝트 명] --template react
cd [프로젝트명]
# 패키지 설치 및 실행

yarn
yarn dev
# 라이브러리 설치

yarn add styled-components @mui/material @emotion/react @emotion/styled @mui/icons-material
yarn add axios qs form-data dotenv
yarn add -D @types/qs --@types/node @types/styled-components

Node.js (Express)

# 프로젝트 생성

npm init -v
npm install express typescript @types/express 
# 패키지 설치 및 실행

npm install axios form-data dotenv multer fs cors
npm install --D nodeman @types/node ts-node
# tsconfig.json 생성

tsc --init
// tsconfig.json

{
  "compilerOptions": {
    "esModuleInterop": true,
    "target": "ES6",
    "module": "commonjs",
    "outDir": "./dist"
  },
  "include": ["*.ts"],
  "exclude": ["node_modules"]
}

2. 인증 토큰 발급

인증 토큰 발급 방법

VITO Developers 사이트에서 회원가입 후 SECRET (client_id, client_secret) 정보를 발급 받으세요
  1. VITO Developers 사이트 회원가입
  2. 콘솔에 접속
  3. 애플리케이션 이름 등록 후, ID와 Secret을 저장
    (현재 화면에서만 Secret을 확인할 수 있으므로 반드시 별도로 저장)

서버에서 JWT 인증 토큰 요청하기

  1. 루트 디렉토리에 .env 파일 생성 후 CLIENT_IDCLIENT_SECRET 작성
# .env

CLIENT_ID="{YOUR_CLIENT_ID}"
CLIENT_SECRET="{YOUR_CLIENT_SECRET}"
  1. 인증 토큰을 요청하는 함수 생성
// config/index.ts

import dotenv from "dotenv";

dotenv.config({ path: ".env" });

export const CLIENT_ID = process.env.CLIENT_ID;
export const CLIENT_SECRET = process.env.CLIENT_SECRET;
export const API_BASE = "https://openapi.vito.ai";

.env 파일에서 필요한 정보를 불러와서 사용

3. Node.js (Express) API

POST 파일 전사 요청

1. Binary형식의 file과 Config를 form-data형태로 Body에 담아 서버에 전달
2. Authorization 헤더를 통해 Access Token 전달
3. 이후 서버에서 transcribe_id 반환
// utils/index.ts

export const config: ConfigType = {
  use_diarization: true,
  diarization: { spk_count: 1 },
  use_multi_channel: false,
  use_itn: true,
  use_disfluency_filter: true,
  use_profanity_filter: false,
  use_paragraph_splitter: true,
  paragraph_splitter: { max: 50 },
};

export const transcribeFile = async (
  file: express.Multer.File,
  access_token: string | undefined
): Promise<string | undefined> => {
  const form = new FormData();
  form.append("config", JSON.stringify(config));
  form.append("file", fs.createReadStream(file.path));

  try {
    const response = await axios.post(`${API_BASE}/v1/transcribe`, form, {
      headers: {
        ...form.getHeaders(),
        Authorization: `bearer ${access_token}`,
      },
    });
    if (response) {
      return response.data.id;
    }
  } catch (error: any) {
    if (error instanceof AxiosError) {
      console.log(error.message);
    }
  }
};

GET 전사 결과 조회

1. 파일 전사 요청 후 반환받은 transcribe_id를 Get방식으로 서버에 요청하여 결과 조회
2. Polling 방식을 통해 completed status를 반환할 때까지 주기적으로 API 요청
3. results.msg를 배열에 담아 클라이언트에 반환
// utils/index.ts

export const getTranscribeResult = async (
  access_token: string | undefined,
  transcribe_id: string | undefined
): Promise<string[] | undefined> => {
  return new Promise((resolve, reject) => {
    const poll = setInterval(async () => {
      try {
        const response = await axios.get(
          `${API_BASE}/v1/transcribe/${transcribe_id}`,
          {
            headers: {
              Authorization: `bearer ${access_token}`,
            },
          }
        );
        const status = response.data.status;
        if (status === "completed") {
          clearInterval(poll);

          const messages = response.data.results.utterances.map(
            (utterance: any) => utterance.msg
          );
          resolve(messages);
        } else if (status === "failed") {
          clearInterval(poll);
          reject("Transcription failed");
        }
      } catch (error: any) {
        clearInterval(poll);
        if (error instanceof AxiosError) {
          console.log(error.message);
        }
        reject(error);
      }
    }, POLLING_INTERVAL);
  });
};

Router 설정

// routes/index.ts

import express from "express";
import multer from "multer";
import { getAccessToken, transcribeFile, getTranscribeResult } from "../utils";

const router = express.Router();
const upload = multer({ dest: "uploads/" });

router.post("/api/transcribe", upload.single("file"), async (req, res) => {
  try {
    const file = req.file;
    if (!file) {
      return res.status(400).send("File is required");
    }

    const access_token = await getAccessToken();
    const transcribe_id = await transcribeFile(file, access_token);
    const result = await getTranscribeResult(access_token, transcribe_id);

    res.send(result);
  } catch (error: any) {
    console.error("Error", error);
    res.status(500).send("Internal Server Error");
  }
});

export default router;

4. UI 작성

💡
본 튜토리얼에서는 VITO Developers 사이트의 VITO Speech 성능 테스트 화면을 참조하여 클론코딩을 진행하였습니다. 본인의 프로젝트 요구사항에 맞추어 UI 코드를 작성해주세요. 로직 위주의 설명을 위해 UI 코드는 생략하겠습니다.

파일 업로드 컴포넌트 생성

// src/components/Upload.tsx

import TestDescription from "./TestDescription";
import IconButton from "./IconButton";
import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined";

const Upload = () => { 
	return (
	    <>
        <TestDescription />
	      <input
	        type="file"
	        style={{ display: "none" }}
	        id="upload-button"
	      />
	      <label htmlFor="upload-button">
	        <IconButton
	          icon={FileUploadOutlinedIcon}
	          title="업로드 하기"
	          color="#3a89ff"
	        />
	      </label>
	    </>
	  );
	};
export default Upload;

결과 및 로딩 컴포넌트 생성

// src/components/TextResult.tsx

import { styled } from "styled-components";
import TextItem from "./TextItem";

const TextResult = ({
  resultTexts,
}: {
  resultTexts: string[] | null | undefined;
}) => {
  return (
    <TextResultLayout>
      {resultTexts?.map((text, index) => (
        <TextItem key={index} text={text} />
      ))}
    </TextResultLayout>
  );
};

export default TextResult;

5. React API 호출

전사 결과를 조회하는 API 호출 함수 생성

// src/services/transcribe.ts

import axios, { AxiosError } from "axios";

export const getTranscribeResultNode = async (file: File) => {
  try {
    const formData = new FormData();
    formData.append("file", file);

    const response = await axios.post(
      `http://localhost:8000/api/transcribe`,
      formData
    );

    return response.data;
  } catch (error: any) {
    if (error instanceof AxiosError) {
      console.log(error.message);
    }
  }
};

파일 업로드 관련 로직을 처리하는 Upload 컴포넌트 생성

💡
[상태 변수]
- isLoading, isResult: 로딩 상태와 결과 표시 상태 저장
- inputRef: 파일 입력 요소의 참조 생성
- resultTexts: 변환된 텍스트 결과 저장
- filename: 선택한 파일 이름 저장
// src/components/Upload.tsx

const Upload = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [isResult, setIsResult] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const [resultTexts, setResultTexts] = useState<string[] | null | undefined>(
    null
  );
  const [filename, setFilename] = useState<string>("");

   const uploadFile = async (file: File | null) => {
    if (file) {
      setIsLoading(true);
      try {
        getTranscribeResultNode(file)
          .then((result) => {
            setIsResult(true);
            setResultTexts(result);
          })
          .catch((error) => {
            console.error(error);
          })
          .finally(() => {
            setIsLoading(false);
          });
      } catch (error) {
        console.error(error);
        setIsLoading(false);
      }
    }
  };

  const reset = () => {
    setIsResult(false);
    setResultTexts(null);
  };

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    if (files) {
      const file = files[0];
      setFilename(file.name);
      uploadFile(file);
    }
    setIsResult(false);
  };

  return (
    <>
      {isLoading && <Loading filename={filename} />}
      {isResult ? (
        <TextResult resultTexts={resultTexts} />
      ) : (
        <>
          <TestDescription />
        </>
      )}
     // 기존 코드 생략
      </label>
    </>
  );
};

export default Upload;

6. 리팩토링

이전 단계까지만 해도 정상적으로 작동하지만, Upload.tsx에 모든 상태 관리 로직이 있어 가독성이 떨어집니다. Custom hook을 통해 관심사를 분리하고 코드의 구조를 더욱 명확하게 만들어보겠습니다.

Custom hook 생성

useUpload 를 생성하여 파일 업로드 및 변환 작업을 수행하는 함수와 결과 상태를 초기화하는 함수 정의

// src/hooks/useUpload.ts

const useUploadNode = () => {
  // 기존 상태 변수 코드 생략

  const uploadFile = async (file: File | null) => {
    // 기존 코드 생략
  };

  const reset = () => {
    // 기존 코드 생략
  };

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
     // 기존 코드 생략
  };

  return {
    isLoading,
    isResult,
    resultTexts,
    filename,
    inputRef,
    reset,
    handleFileChange,
  };
};

export default useUploadNode;

Custom hook이 적용된 Upload.tsx

const Upload = () => {
  const {
    isLoading,
    isResult,
    resultTexts,
    filename,
    inputRef,
    reset,
    handleFileChange,
  } = useUpload();

  return (
    <>
      // 기존 코드 생략
    </>
  );
};

export default Upload

7. 최종 결과

실행 방법

서버 실행 방법

// package.json 설정

"scripts": {
    "test": "echo \\"Error: no test specified\\" && exit 1",
    "dev": "nodemon --watch \\"*.ts\\" --exec \\"ts-node\\" app.ts" // 추가
  },
npm run dev

클라이언트 실행 방법

yarn dev

서버 실행 결과

클라이언트 실행 결과

샘플 음성파일

https://blog.naver.com/adsound_rec/221835489818

디렉토리 구조

node-server
├─ .prettierrc
├─ README.md
├─ app.ts
├─ config
│  └─ index.ts
├─ package-lock.json
├─ package.json
├─ routes
│  └─ index.ts
├─ tsconfig.json
├─ types
│  └─ index.ts
└─ utils
   └─ index.ts
react-project
├─ .env
├─ .eslintrc.cjs
├─ .prettierrc
├─ README.md
├─ index.html
├─ package.json
├─ public
│  ├─ VitoLogo.png
│  ├─ audio.svg
│  ├─ audio_off.svg
│  └─ spinner.svg
├─ src
│  ├─ App.tsx
│  ├─ components
│  │  ├─ BasicTabs.tsx
│  │  ├─ Header.tsx
│  │  ├─ IconButton.tsx
│  │  ├─ Loading.tsx
│  │  ├─ Realtime.tsx
│  │  ├─ TabItem.tsx
│  │  ├─ TestDescription.tsx
│  │  ├─ TextItem.tsx
│  │  ├─ TextResult.tsx
│  │  └─ Upload.tsx
│  ├─ hooks
│  │  └─ useUpload.ts
│  ├─ index.css
│  ├─ main.tsx
│  ├─ services
│  │  └─ transcribe.ts
│  ├─ styles
│  │  └─ global-styles.ts
│  ├─ types
│  │  └─ transcribe.ts
│  └─ vite-env.d.ts
├─ tsconfig.json
├─ tsconfig.node.json
├─ vite.config.ts
└─ yarn.lock


Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to 기업을 위한 음성 AI - 리턴제로 blog.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.