Speech to Text with Web Speech API

June 16, 2025

Web Speech API로 음성을 텍스트로 변환하기 (STT)

이전에 사이드 프로젝트를 하면서 음성을 텍스트로 바꾸는 기능을 구현한 적이 있는데 브라우저만으로 이런 기능을 무료로 사용할 수 있다는게 재미있었고 당시 급하게 기능구현만 하느라 다시 한번 학습을 하고 싶었습니다.

Web Speech API - SpeechRecognition 인터페이스

SpeechRecognition은 Web Speech API 중 하나로, 사용자의 음성을 텍스트로 변환하는 STT(Speech-to-Text) 기능을 제공합니다.

이 인터페이스는 브라우저의 음성 인식 엔진을 제어하는 컨트롤러 역할을 하며, 인식 결과를 담은 SpeechRecognitionEvent 객체도 함께 처리합니다.

📘 MDN 설명:

“The SpeechRecognition interface of the Web Speech API is the controller interface for the recognition service; this also handles the SpeechRecognitionEvent sent from the recognition service.”

⚠️ 브라우저 호환성 주의

모든 브라우저에서 SpeechRecognition을 지원하는 것은 아닙니다.

👉 SpeechRecognition 브라우저 호환성 확인 (MDN)

TypeScript에서 사용할 때 주의할 점

Web Speech API는 아직 공식적인 TypeScript 타입 정의가 포함되어 있지 않습니다. 과거에는 @types/webspeechapi 패키지를 설치하여 사용했던것 같은데 현재는 deprecated(더 이상 유지보수되지 않음) 상태로 그냥 사용하거나 따로 직접 타입을 선언해서 사용해야됩니다.

Web Speech API 가 아직 일부 브라우저에서만 제한적으로 지원되고 있기 때문이 아닐까 싶습니다.

npm install -D @types/webspeechapi

저는 @types/webspeechapi 패키지를 설치하여 테스트를 했습니다.

구현하기

1. VoiceRecorder

//voice-recorder.tsx
import {
  SpeechRecognitionLanguage,
  useSpeechRecognition,
} from "@/hooks/use-speech-recognition";

const languageOptions: { code: SpeechRecognitionLanguage; label: string }[] = [
  { code: "ko-KR", label: "한국어" },
  { code: "en-US", label: "English (US)" },
  { code: "ja-JP", label: "日本語" },
  { code: "zh-CN", label: "中文 (简体)" },
  { code: "fr-FR", label: "Français" },
  { code: "es-ES", label: "Español" },
];

export default function VoiceRecorder() {
  const [lang, setLang] = useState<SpeechRecognitionLanguage>("ko-KR");
  const {
    transcript,
    isListening,
    startListening,
    stopListening,
    resetTranscript,
  } = useSpeechRecognition({
    lang,
    onError: (event) => {
      console.log("음성 인식 에러 발생:", event.error);
    },
  });

  const handleToggleRecording = () => {
    if (isListening) {
      stopListening();
    } else {
      startListening();
    }
  };

  const handleResetTranscript = () => {
    resetTranscript();
  };

  return (
    <div className="flex flex-col items-center justify-center h-screen gap-4 px-4 text-center">
      <h1 className="text-2xl font-bold">Speech to Text</h1>

      {/* 언어 선택 */}
      <select
        className="select border"
        value={lang}
        onChange={(e)=> setLang(e.target.value as SpeechRecognitionLanguage)}
        disabled={isListening}
      >
        {languageOptions.map(({ code, label }) => (
          <option key={code} value={code}>
            {label}
          </option>
        ))}
      </select>

      {/* 음성 인식 상태 표시 */}
      <button
        className={`px-4 py-2 text-white rounded cursor-pointer w-48 ${
          isListening ? "bg-red-500" : "bg-blue-500"
        }`}
        onClick={handleToggleRecording}
      >
        {isListening ? "음성 인식 중지" : "음성 인식 시작"}
      </button>

      {/* STT 상태 표시 */}
      <span className="text-sm text-gray-500">
        {isListening ? "🎙️ 음성 인식 중..." : "🛑 음성 인식 대기 중"}
      </span>

      {/* 녹음 텍스트 */}
      <div className="min-h-[4rem] mt-2 text-gray-700 whitespace-pre-line">
        {transcript || "🎤 음성을 입력해보세요..."}
      </div>

      {/* 리셋 버튼 */}
      <button
        className="mt-2 px-3 py-1 text-sm text-gray-700 bg-gray-200 rounded hover:bg-gray-300 cursor-pointer"
        onClick={handleResetTranscript}
        disabled={!transcript}
      >
        🧹 텍스트 지우기
      </button>
    </div>
  );
}

2. useSpeechRecognition.ts

import { useRef, useState } from "react";

export type SpeechRecognitionLanguage =
  // 아시아
  | "ko-KR" // 한국어 (대한민국)
  | "ja-JP" // 일본어 (일본)
  | "zh-CN" // 중국어 (중국 - 간체)
  | "zh-TW" // 중국어 (대만 - 번체)
  | "zh-HK" // 중국어 (홍콩 - 번체)
  | "th-TH" // 태국어 (태국)
  | "hi-IN" // 힌디어 (인도)
  | "id-ID" // 인도네시아어 (인도네시아)
  | "ms-MY" // 말레이어 (말레이시아)

  // 유럽
  | "en-GB" // 영어 (영국)
  | "en-US" // 영어 (미국)
  | "en-AU" // 영어 (호주)
  | "en-CA" // 영어 (캐나다)
  | "en-IN" // 영어 (인도)
  | "de-DE" // 독일어 (독일)
  | "fr-FR" // 프랑스어 (프랑스)
  | "fr-CA" // 프랑스어 (캐나다)
  | "it-IT" // 이탈리아어 (이탈리아)
  | "es-ES" // 스페인어 (스페인)
  | "es-MX" // 스페인어 (멕시코)
  | "pt-BR" // 포르투갈어 (브라질)
  | "pt-PT" // 포르투갈어 (포르투갈)
  | "nl-NL" // 네덜란드어 (네덜란드)
  | "ru-RU" // 러시아어 (러시아)
  | "pl-PL" // 폴란드어 (폴란드)
  | "sv-SE" // 스웨덴어 (스웨덴)
  | "da-DK" // 덴마크어 (덴마크)
  | "fi-FI" // 핀란드어 (핀란드)
  | "no-NO" // 노르웨이어 (노르웨이)

  // 아프리카 및 중동
  | "ar-SA" // 아랍어 (사우디아라비아)
  | "ar-EG" // 아랍어 (이집트)
  | "tr-TR" // 터키어 (터키)
  | "fa-IR" // 페르시아어 (이란)
  | "he-IL" // 히브리어 (이스라엘)
  | "af-ZA"; // 아프리칸스어 (남아프리카공화국)

interface UseSpeechRecognitionOptions {
  lang?: SpeechRecognitionLanguage;
  onError?: (error: SpeechRecognitionError) => void;
}

export const useSpeechRecognition = ({
  lang = "ko-KR",
  onError,
}: UseSpeechRecognitionOptions = {}) => {
  const recognitionRef = useRef<SpeechRecognition | null>(null);
  const [isListening, setIsListening] = useState(false);
  const [transcript, setTranscript] = useState("");

  const startListening = () => {
    if (
      !("webkitSpeechRecognition" in window || "SpeechRecognition" in window)
    ) {
      console.error("Speech recognition not supported");
      return;
    }

    const SpeechRecognition =
      window.SpeechRecognition || window.webkitSpeechRecognition;
    const recognition = new SpeechRecognition();
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = lang;

    recognition.onstart = () => {
      setIsListening(true);
    };

    recognition.onresult = (event: SpeechRecognitionEvent) => {
      let finalTranscript = "";
      for (let i = event.resultIndex; i < event.results.length; ++i) {
        const result= event.results[i];
        if (result.isFinal) {
          finalTranscript = result[0].transcript;
        }
      }

      if (finalTranscript) {
        setTranscript((prev)=> prev + finalTranscript);
        console.log("Final result:", finalTranscript);
      }
    };

    recognition.onerror= (event: SpeechRecognitionError)=> {
      console.error("Speech recognition error", event.error);
      setIsListening(false);
      onError?.(event);
    };

    recognition.onend= ()=> {
      setIsListening(false);
    };

    recognition.start();
    recognitionRef.current= recognition;
  };

  const stopListening= ()=> {
    recognitionRef.current?.stop();
    setIsListening(false);
  };

  const resetTranscript= ()=> {
    setTranscript("");
  };

  return {
    isListening,
    transcript,
    startListening,
    stopListening,
    resetTranscript,
  };
};

핵심 포인트 정리

1. continuous

  • true로 설정하면 끊기지 않고 계속 음성을 인식합니다.
  • 기본값은 false이며, 이 경우 사용자가 잠시 말을 멈추면 자동으로 인식이 종료됩니다.

2. interimResults

  • true일 경우 중간 인식 결과(확정되지 않은 텍스트)도 받아올 수 있습니다.
  • 중간 결과는 isFinal: false 상태로 event.results[i]에 담겨 옵니다.

3. lang

  • 인식할 언어 (예: "ko-KR", "en-US")

4. onstart, onend

  • 인식 시작/종료 시점 콜백

5. onresult

  • 음성 인식 결과 콜백
  • event.results는 이중 배열로 구성되며, 각 결과에는 isFinal 속성으로 최종 확정 여부가 표시

6. onerror

  • 인식 실패 시 콜백

7. start(), stop()

  • 인식 시작/정지 메서드

특이사항

하나 특이했던건 음성 인식이 일정시간 안되고 있으면 “no-speech” 라는 에러 메시지와 함께 음성 인식이 종료된다는 거였습니다. 이로인해 음성 인식과 동시에 녹음을 한다거나 음량 시각화를 표시할 경우엔 이 종료 시점을 함께 고려해야 될 거 같았습니다.

작동 예제

Reference