본문 바로가기
  • 삽질하는 자의 블로그
메인-프로젝트/MERN - 다이어리 프로젝트

5.[클라이언트] - WEB SPEECH API 기반, 음성 인식 라이브러리 사용하기

by 이게뭐당가 2023. 2. 21.

react-speech-recognition 음성인식 라이브러리를 사용한다.

install :       npm i react-speech-recognition
ts install :    npm install --save @types/react-speech-recognition
기본 readme :   https://www.npmjs.com/package/react-speech-recognition
codepan example : https://codesandbox.io/s/react-speech-recognition-3tjwt?file=/src/Dictaphone.tsx:460-486

 

나는 타입스크립트이므로, 타입스크립트용을 설치한다.

 


 

구성 목표

< 기능 >

    1. [보이스모드, 일반 모드] 를 만든다.

    2. [보이스모드], [일반모드] 의 변환은, "변환버튼" 을 눌러 바꾸게 한다.

    3. "변환버튼" 은 현재의 모드를 표시해주고, 누르면 바뀐다.

    4. [보이스모드] 에서는 텍스트창을 손으로 건들 수 없다.
    5. [일반모드] 에서는 마이크를 사용할 수 없다.

    6. 두가지 모드들은 한 컴포넌트 안에 뭉쳐둔다.

    7. [보이스모드] start 를 "마이크버튼" 으로 만든다.
    8. [보이스모드] "마이크버튼을 한번 누르면 실행, 한번 더누르면 종료"
    9. [보이스모드] reset 버튼을 누르면, 텍스트 삭제

        ** 문제 : 일반메시지와 보이스 메시지를 섞어가며 사용하고 싶은데
                잘안된다.

<일반모드, 보이스모드 한꺼번에 사용가능하도록 섞는 로직>

    * textarea 의 value 는 어차피 변하지 않고, 유저가 입력한 값을 유지한다.
    * 최종적으로 "diaryContent" 에 들어갈 값 = "message"
    * 잠시 보이스 값을 담아두는 값 = "transcript"

    1. [일반모드] 에서 넣는 값은 "onChange" 를 통해 "message" 안에 들어간다.

        따라서, 일반 textarea 는

            <textarea onChange={(e) => changeMessage(e.target.value)}> </textarea>

    2. [보이스모드] 로 변경된다면, 보이스 값은 "transcript" 안에 들어간다.  

        따라서, 보이스의 textarea 는

            <textarea value={message + transcript}> </textarea>

    3. [모드] 변경시, useEffect 를 통해서 
        "message" 안의 값을 "message + transcript" 로 변경하고
        "transcript" 의 값을 초기화시킨다.

            useEffect(() => {
                setMessage((prev) => prev + transcript);
                resetTranscript();
            }, [toggleVoiceMode]);

    ==> textarea 의 값은 [일반모드] 일 경우 당연히 그대로 있고
        [보이스모드] 의 경우에도, 
        기존의 value 인 "message" State 에 
        추가적으로 보이스로 입력한, "transcript" 도 적히기 때문에
        모드를 변경하더라도 텍스트가 유지된다.

< 제출>
    [ 일반모드] 에서 diaryContent 제출시 , "message" State 만 제출하면 된다.
    [ 보이스모드] 에서 diaryContent 제출시, "message + transcript" 를 해서 제출한다.

 


 

라이브러리의 기본 사용 코드 해석

 

import React from 'react';
import SpeechRecognition, { useSpeechRecognition } from 'react-speech-recognition';

const Dictaphone = () => {
  const {
    transcript,		// 마이크로 인식한 텍스트 저장
    listening,		// 마이크 ON, OFF 상태 저장
    resetTranscript,	// RESET 함수
    browserSupportsSpeechRecognition	// 기본 함수
  } = useSpeechRecognition();

  if (!browserSupportsSpeechRecognition) {
    return <span>Browser doesn't support speech recognition.</span>;	// 브라우저가 지원하지않을경우
  }

  return (
    <div>
      <p>Microphone: {listening ? 'on' : 'off'}</p>
      <button onClick={SpeechRecognition.startListening}>Start</button>	// 실행
      <button onClick={SpeechRecognition.stopListening}>Stop</button>	// 중지
      <button onClick={resetTranscript}>Reset</button>		// 리셋
      <p>{transcript}</p>
    </div>
  );
};
export default Dictaphone;

 


내가 세운 작업 계획을 따라가보자

 

1. 모드 전환 버튼 만들기

import SpeechRecognition, {useSpeechRecognition} from "react-speech-recognition";

const DiaryMain = () => {
  const [toggleVoiceMode, setToggleVoiceMode] = useState<boolean>(false);
  const [content, setContent] = useState<string>("");	// 일반모드에서 input 값을 저장할곳

    // 모드변경시, 보이스로 저장한 값을 일반 값에 추가하고, 보이스 값을 삭제한다.
    useEffect(() => {
        setContent((prev) => prev + transcript);
        resetTranscript();
    }, [toggleVoiceMode]);


    // 모드 변경 핸들러
    const changeMode = () => {
    	setToggleVoiceMode((prev) => !prev);
    };
    
  return (
		...
       <div className={styles.form__changeMode} onClick={changeMode}>
          {toggleVoiceMode ? (
            <p>보이스모드</p>
          ) : (
            <p>일반 모드</p>
          )}
        </div>

 

이미지를 사용하여,  모드전환버튼을 만든다.

이벤트 리스너를 할당하기 편하게 하기 위하여, div 로 감싸준다.

 

모드 변경시,

보이스모드로 저장한 값(transcipt)을 일반 값(content State) 에 추가하고,

보이스모드로 저장한 값은 삭제한다.

 

content 안에는 실제로 넘어갈 데이터가 존재한다.
transcript 안에는 보이스모드로 저장된 글자 데이터가 존재한다.

보이스 모드에서 일반보드로 여러번 넘어갈 경우
보이스 모드의 값은 따로 지워지지 않고 유지되어

일반 입력 : 일반일반
보이스입력 : 보이스보이스
일반 입력 : 다시일반
보이스 입력 : 다시보이스

처럼 입력하게된다면,

“일반일반” + “보이스보이스” + “다시일반” + “보이스보이스 다시보이스“

와 같이, 보이스로 저장된 값이 누적되는 결과가 나오게된다.

따라서 토글을 하게되면, 기존의 보이스 값을, 
일반 content 에 넘기고 보이스에 저장된 데이터는 삭제한다.

 

 

2. MIC ON, OFF 의 생성

 

import SpeechRecognition, {useSpeechRecognition} from "react-speech-recognition";

const DiaryMain = () => {
  const [toggleVoiceMode, setToggleVoiceMode] = useState<boolean>(false);
  const {
        transcript, // 마이크로 인식한 텍스트
        listening, // 마이크 on off 인식
        resetTranscript, // 리셋 함수
        browserSupportsSpeechRecognition,
    } = useSpeechRecognition();

    // 보이스 시작 함수 설정
    const listenContinuously = () => {
        SpeechRecognition.startListening({
          continuous: true,
          language: "ko",
        });
    };

    return (
      {toggleVoiceMode && (
        <div className={styles.diary__voiceToggle}>
          <div>
            {listening ? (
              <div onClick={SpeechRecognition.stopListening}>
                <img src="/images/common/mic.png" alt="mic-on" width={50} />	// MIC ON
              </div>
            ) : (
              <div onClick={listenContinuously}>
                <img src="/images/common/mic-cancel.png" alt="mic-off" width={50} />	// MIC OFF
              </div>
            )}
          </div>
        </div>
      )}

마이크가 ON 일경우, OFF 일 경우에 따른 이미지를 활용한다.

미리 정의한 보이스 시작 함수를 사용해 시작하고

종료함수를 통해 종료한다.

listening 변수를 통해 해당 사실을 알 수 있다.

 

타입스크립트 에서는, 반드시 speech 를 시작하는 옵션항목을 명명해주어야 하므로,

보이스 시작 함수 옵션을 포함해 확실히 설정하고 적용시키도록한다.

 

 

3. INPUT FORM 생성

 


    // 토글된 보이스모드에 따른 message 의 생성
    const changeMessage = (value: string) => {
        setContent(value);
    };

     return (
          <form className={styles.form} onSubmit={submitHandler}>
            <div className={styles.form__changeMode} onClick={changeMode}>
                ...

             {toggleVoiceMode ? (
              <textarea
                className={styles.form__texts}
                style={{ width: "90%", height: "80%", resize: "none" }}	// 보이스용
                value={content + transcript}
              ></textarea>
            ) : (
              <textarea
                className={styles.form__texts}
                style={{ width: "90%", height: "80%", resize: "none" }}	// 일반용
                onChange={(e) => changeMessage(e.target.value)}
                value={content}
              ></textarea>
            )}

 

일반 모드의 경우, 기존의 input 처럼 onChange 리스너를 활용해 State 에 값을 전달하고, 그 값을 자신의 값으로 만든다.

 

보이스모드의 경우, value 를 설정하면 ReadOnly 상태가 된다.

따라서, 해당 textarea 는 조작할 수 없게 된다.

 

보이스모드의 경우 기존의 타자로 조작하던 content State 와 더불어,

보이스로 말했을때 저장되는 transcript 를 추가시켜 value 로 만들어준다.

 

4. 제출 핸들러 설정

 

      // 제출 핸들러
      const submitHandler = async (e: FormEvent) => {
        e.preventDefault();

        const response = await fetch(`http://localhost:5000/api/diary/insert`, {
          method: "POST",
          body: JSON.stringify({
            userEmail: dummyuUserEmail,
            diaryTitle: title,
            diaryContent: toggleVoiceMode ? content + transcript : content,
            feeling: feeling,
            date: new Date().toLocaleDateString("ko-KR"),
          }),
          headers: {
            "Content-Type": "application/json",
          },
        });

        const responseData = await response.json();

        if (responseData.message === "success") {
          resetContents();
        }
      };

 

DB 에 넣어본다.

 

 

추가사항. 키보드로 조작하게 만들기

 

  // 키보드로 모드 변경
  const keyboradModeChangeHandler = (
    e: React.KeyboardEvent<HTMLInputElement>
  ) => {
    if ((e.ctrlKey && e.key === "Alt") || (e.altKey && e.key === "Control")) {
      listening ? SpeechRecognition.stopListening() : listenContinuously();
    }
    if (
      (e.shiftKey && e.key === "Control") ||
      (e.ctrlKey && e.key === "Shift")
    ) {
      changeMode();
    }
  };
  
  return (
    <div className={styles.diary} onKeyDown={keyboradModeChangeHandler}>
      <DiaryAside />
      	...

 

React.keyboardEvent<HTMLInputElement> 타입을 활용하여,

키보드 조작을 실행할 수 있도록 한다.

 

 

최종코드

import DiaryAside from "./diary-aside";
import styles from "./diary-main.module.scss";
import SpeechRecognition, {
  useSpeechRecognition,
} from "react-speech-recognition";
import { useState, useEffect, FormEvent } from "react";

const DiaryMain = () => {
  const dummyuUserEmail = "mms@ms.com"; // 추후 OAuth 로 변경
  const [toggleVoiceMode, setToggleVoiceMode] = useState<boolean>(false);
  const [content, setContent] = useState<string>("");
  const [title, setTitle] = useState<string>("");
  const [feeling, setFeeling] = useState<number>(5);

  const {
    transcript, 		// 마이크로 인식한 텍스트
    listening, 			// 마이크 on off 인식
    resetTranscript, 		// 리셋 함수
    browserSupportsSpeechRecognition,
  } = useSpeechRecognition({
    commands: [
      {
        command: "보이스 모드 종료",
        callback: () => setToggleVoiceMode(false),
      },
    ],
  });

  useEffect(() => {
    setContent((prev) => prev + transcript);	// 모드변경시, 일반 State 에 옮기고, 보이스 글자 삭제
    resetTranscript();
  }, [toggleVoiceMode]);

  if (!browserSupportsSpeechRecognition) {
    return <span>Browser doesn't support speech recognition.</span>;
  }

  // 키보드로 모드 변경
  const keyboradModeChangeHandler = (
    e: React.KeyboardEvent<HTMLInputElement>
  ) => {
    if ((e.ctrlKey && e.key === "Alt") || (e.altKey && e.key === "Control")) {
      listening ? SpeechRecognition.stopListening() : listenContinuously();
    }
    if (
      (e.shiftKey && e.key === "Control") ||
      (e.ctrlKey && e.key === "Shift")
    ) {
      changeMode();
    }
  };

  // 보이스 시작 함수 설정
  const listenContinuously = () => {
    SpeechRecognition.startListening({
      continuous: true,
      language: "ko",
    });
  };

  // 모드 변경 핸들러
  const changeMode = () => {
    setToggleVoiceMode((prev) => !prev);
  };

  // 리셋 핸들러
  const resetContents = () => {
    resetTranscript();
    setContent("");
    setTitle("");
  };

  // 토글된 보이스모드에 따른 message 의 생성
  const changeMessage = (value: string) => {
    setContent(value);
  };

  // 제출 핸들러
  const submitHandler = async (e: FormEvent) => {
    e.preventDefault();

    const response = await fetch(`http://localhost:5000/api/diary/insert`, {
      method: "POST",
      body: JSON.stringify({
        userEmail: dummyuUserEmail,
        diaryTitle: title,
        diaryContent: toggleVoiceMode ? content + transcript : content,
        feeling: feeling,
        date: new Date().toLocaleDateString("ko-KR"),
      }),
      headers: {
        "Content-Type": "application/json",
      },
    });

    const responseData = await response.json();

    if (responseData.message === "success") {
      resetContents();
    }
  };

  return (
    <div className={styles.diary} onKeyDown={keyboradModeChangeHandler}>	// 키보드 조작함수
      <DiaryAside />
      {toggleVoiceMode && (
        <div className={styles.diary__voiceToggle}>
          <div>
            {listening ? (
              <div onClick={SpeechRecognition.stopListening}>		// 마이크 중지
                <img src="/images/common/mic.png" alt="mic-on" width={50} />
                <span> ctrl + alt </span>
              </div>
            ) : (
              <div onClick={listenContinuously}>		// 마이크 시작
                <img src="/images/common/mic-cancel.png" alt="mic-off" width={50}/>
                <span> ctrl + alt </span>
              </div>
            )}
          </div>
        </div>
      )}

      <form className={styles.form} onSubmit={submitHandler}>		// 제출 함수
        <div className={styles.form__changeMode} onClick={changeMode}>	// 모드변경 함수
          {toggleVoiceMode ? (
            <p>
              보이스모드<span> ctrl + shift</span>
            </p>
          ) : (
            <p>
              일반 모드<span> ctrl + shift</span>
            </p>
          )}
        </div>
        <div className={styles.form__inputs}>
          <div>
            <label htmlFor="date"> 날짜</label>
            <input
              type={"text"}
              id={"date"}
              value={new Date().toLocaleDateString("ko-KR")}
              readOnly
            />
          </div>
          <div>
            <label htmlFor="title"> 제목</label>
            <input
              type={"text"}
              id={"title"}
              value={title}
              onChange={(e) => setTitle(e.target.value)}
            />
          </div>
          <div>
            <label htmlFor="score"> 기분점수</label>
            <input
              type="range"
              id="volume"
              min="1"
              max="9"
              step="1"
              data-action="volume"
              defaultValue={feeling}
              onChange={(e) => setFeeling(Number(e.target.value))}
            />
          </div>
          <div></div>
        </div>
        {toggleVoiceMode ? (
          <textarea
            className={styles.form__texts}
            style={{ width: "90%", height: "80%", resize: "none" }}	// 보이스모드 textarea
            value={content + transcript}
          ></textarea>
        ) : (
          <textarea
            className={styles.form__texts}
            style={{ width: "90%", height: "80%", resize: "none" }}	// 일반모드 textarea
            onChange={(e) => changeMessage(e.target.value)}
            value={content}
          ></textarea>
        )}

        <div className={styles.form__buttons}>
          <button type="submit"> 저장</button>
          <button type="button" onClick={resetContents}>
            지우기
          </button>
        </div>
      </form>
    </div>
  );
};

export default DiaryMain;

 

댓글