by Luna
VITO의 STT API는 다양한 언어에서 쉽게 접근하고 활용할 수 있는 특징을 가지고 있습니다. 공식 문서에서는 주로 Java, Curl, Python 등을 통해 API의 사용 방법을 안내하고 있습니다. 하지만 이번에는 조금 다른 방법으로, TypeScript를 이용하여 React.js와 Node.js에서 파일 STT API를 적용해보는 튜토리얼을 준비했습니다.
TypeScript는 JavaScript의 슈퍼셋으로, 큰 규모의 어플리케이션 개발을 위해 탄생했습니다. 이번 튜토리얼을 통해 TypeScript와 React 환경에서 VITO의 파일 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) 정보를 발급 받으세요
- VITO Developers 사이트 회원가입
- 콘솔에 접속
- 애플리케이션 이름 등록 후, ID와 Secret을 저장
(현재 화면에서만 Secret을 확인할 수 있으므로 반드시 별도로 저장)
서버에서 JWT 인증 토큰 요청하기
- 루트 디렉토리에
.env
파일 생성 후CLIENT_ID
와CLIENT_SECRET
작성
# .env
CLIENT_ID="{YOUR_CLIENT_ID}"
CLIENT_SECRET="{YOUR_CLIENT_SECRET}"
- 인증 토큰을 요청하는 함수 생성
3. Node.js (Express) API
POST
파일 전사 요청
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
전사 결과 조회
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 작성
파일 업로드 컴포넌트 생성
// 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