실시간 음성을 텍스트로 변환하기
오늘은 해커톤에서 실제 사용했던 실시간 음성을 텍스트로 변환하는 기능에 대해 설명하려고 한다.
사용자에게 음성을 입력으로 받은 다음 그 음성을 텍스트로 변환해 사용자에게 보이는 화면에 변화를 줄 수 있다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/annyang/2.6.1/annyang.min.js"></script>
<script type="module" src="https://unpkg.com/@google/model-viewer"></script>
</head>
<body>
<!-- home 팝업화면 -->
<div class="popupWrap">
<div class="popupBody">
<div class="closeIcon" onclick="togglePopup()">
<img src="{{ url_for('static', path='image/whiteCloseIcon.png') }}">
</div>
</div>
<div class="popupModel">
<img id="rabbitModel2" src="{{ url_for('static', path='image/ham_pngver.png') }}">
</div>
<div class="bottomCircle"></div>
<div class="popupBlink">마이크를 눌러 귀여운 친구의 이름을 정해주세요.</div>
<div class="recording">
<img id="nameRecord" src="{{ url_for('static', path='/image/nameRecord.png') }}" onclick="hideText()">
<img id="record" src="{{ url_for('static', path='/image/record.png') }}" onclick="startText()">
<img id="stop" src="{{ url_for('static', path='/image/stop.png') }}" onclick="stopText()">
</div>
<p id="result">여러분의 목소리가 여기에 표시 돼요.</p>
<div id="status-indicator">녹음 전</div>
<!-- 제출 버튼 -->
<button id="submit-btn">제출</button>
</div>
</body>
</html>
css, js 연결 코드나 관련 없는 코드는 제외시켰다.
html 코드는 위와 같다. head 태그 내부의 script 두 줄을 추가해주면 된다.
// 음성 인식
document.addEventListener('DOMContentLoaded', () => {
const modelViewer = document.getElementById('rabbitModel2');
const nameRecordBtn = document.getElementById('nameRecord');
const recordBtn = document.getElementById('record');
const stopBtn = document.getElementById('stop');
const resultElem = document.getElementById('result');
const statusIndicator = document.getElementById('status-indicator');
const recordingStatusIndicator = document.getElementById('recording-status-indicator');
const submitBtn = document.getElementById('submit-btn'); // 제출 버튼
const homeNameRecordBtn = document.getElementById('homenameRecord');
const homeStopBtn = document.getElementById('homestop');
const bedContainer = document.getElementById('bedContainer'); // 명상하기 컨테이너
const cookingContainer = document.getElementById('cookingContainer');
const waterContainer = document.getElementById('waterContainer');
const cleanContainer = document.getElementById('cleanContainer');
const washContainer = document.getElementById('washContainer');
let recognition;
let isListening = false;
let userName = '';
if ('webkitSpeechRecognition' in window) {
recognition = new webkitSpeechRecognition();
recognition.continuous = true; //연속적으로 인식하도록(이거 false하면 사용자의 음성이 끊기면 자동으로 녹음 중단됨)
recognition.interimResults = false; //중간 결과 반환 false
recognition.lang = "ko-KR"; //한국어로 설정
recognition.onresult = (event) => {
const transcript = event.results[event.results.length - 1][0].transcript.trim();
console.log(transcript);
if (!userName) {
userName = transcript.toLowerCase();
resultElem.textContent = `이름 : ${userName}`;
} else {
// 단어별로 나누고, 이름과 정확히 일치하는 경우만 강조
const words = transcript.split(' ');
const highlightedText = words.map(word => {
if (word.toLowerCase() === userName) {
return `<span class="highlight">${word}</span>`;
} else {
return word;
}
}).join(' ');
resultElem.innerHTML = `나 : ${highlightedText}`;
modelViewer.style.marginBottom = "10rem";
if (modelViewer) {
modelViewer.classList.add('move-up');
}
// 모델 뷰어 이동 애니메이션
if (transcript === '오른쪽') {
modelViewer.classList.add('move-right');
} else if (transcript === '왼쪽') {
modelViewer.classList.add('move-left');
} else if (transcript == userName) {
modelViewer.classList.add('move-up');
}
setTimeout(() => {
modelViewer.classList.remove('move-up','move-right', 'move-left');
}, 7000);
// "대화하자"를 말했을 때 /chat 경로로 이동
if (transcript === '대화하자') {
window.location.href = '/chat';
}
else if (transcript === '산책하자') {
window.location.href = '/walkpage';
}
else if (transcript === '명상하자') {
bedContainer.click(); // "명상하자"를 말했을 때 bedContainer의 클릭 이벤트 트리거
}
else if (transcript === '밥 먹자') {
// cookingContainer.click();
cookingContainer.querySelector('input[type="file"]').click();
}
else if (transcript === '물 먹자') {
waterContainer.click();
}
else if (transcript === '청소하자') {
cleanContainer.click();
}
else if (transcript === '씻자') {
washContainer.click();
}
}
};
recognition.onstart = () => {
statusIndicator.textContent = '듣는 중...';
statusIndicator.classList.add('recording');
statusIndicator.style.color = "#FF9100";
statusIndicator.style.fontFamily = "Pretendard-SemiBold";
//홈 nav record
recordingStatusIndicator.textContent = '듣는 중...';
recordingStatusIndicator.style.display = 'block';
recordingStatusIndicator.style.color = "#71594E";
recordingStatusIndicator.style.fontFamily = "Pretendard-SemiBold";
homeNameRecordBtn.style.opacity = "100%";
};
recognition.onend = () => {
statusIndicator.textContent = '녹음 중지됨';
statusIndicator.classList.remove('recording');
statusIndicator.style.fontFamily = "Pretendard-SemiBold";
statusIndicator.style.color = "#FF8181";
isListening = false;
// 음성 인식 자동 재시작
// recognition.start();
recordingStatusIndicator.textContent = '녹음 중지됨';
recordingStatusIndicator.style.color = "#71594E";
setTimeout(() => {
recordingStatusIndicator.style.display = 'none';
}, 4000); // 4초 후에 사라짐
homeNameRecordBtn.style.opacity = "50%";
homeStopBtn.style.opacity = "100%";
};
nameRecordBtn.addEventListener('click', () => {
userName = ''; // 사용자 이름 초기화
recognition.start();
});
recordBtn.addEventListener('click', () => {
if (!isListening) {
recognition.start();
isListening = true;
}
});
stopBtn.addEventListener('click', () => {
recognition.stop();
});
// 홈 버튼 추가 기능
homeNameRecordBtn.addEventListener('click', () => {
if (!isListening) {
recognition.start();
isListening = true;
}
});
homeStopBtn.addEventListener('click', () => {
recognition.stop();
});
// 제출 버튼 클릭 시 서버에 데이터 전송 -> DB에 저장
submitBtn.addEventListener('click', async () => {
const userName = document.getElementById('result').textContent.replace('이름 : ', '').trim();
if (userName) {
try {
const response = await fetch('/set_custom_name', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ custom_name: userName })
});
if (response.ok) {
const data = await response.json();
alert('이름이 저장되었습니다.');
} else {
throw new Error('네트워크 응답이 올바르지 않습니다.');
}
} catch (error) {
console.error('오류:', error);
alert('이름 저장에 실패했습니다.');
}
} else {
alert('제출할 이름이 없습니다.');
}
});
} else {
resultElem.textContent = '음성 인식 기능을 지원하지 않습니다.';
}
});
js 코드는 위와 같다. 좀 복잡해보일 수 있지만 기능은 총 3개라고 생각하면 된다. 첫 번째는 이름을 설정하는 버튼, 두 번째는 일반 음성을 듣는 버튼, 마지막으로는 멈춤 버튼 총 3가지로 구성되어 있고 특정 버튼들은 눌렀을 때 마이크를 활성화시키거나 멈추는 일을 한다.
먼저 이름 설정하는 버튼으로 사용자에게 음성으로 이름을 입력 받고 해당 이름을 DB에 저장한다. 그 다음 일반적인 음성을 듣는 버튼을 활성화 시킨 뒤 사용자가 초기에 저장한 이름을 말하면 css를 사용해 강조 표시되도록 했다. 또 조건문을 사용해 특정 요소의 위치를 이동시키는 코드를 추가했다. (ex: "오른쪽으로 가" 라고 하면 오른쪽으로 30rem 이동)
이게 팝업 페이지에 있는 코드만 가져오다 보니 다른 코드들을 전부 가져오진 못했는데 팝업 창을 닫고 페이지 상단에 있는 마이크 버튼을 누르고 특정 문장을 말하면 그에 맞는 페이지로 이동하는 코드도 추가했다. 예를 들어 "산책하자" 라는 문장이 입력으로 들어오면 산책하기 페이지로 넘어가게 했다. 우리 서비스가 대화, 산책을 제외하고는 home 페이지에서 클릭을 해야 각 부분에 설정된 팝업이 뜨도록 했는데 그걸 음성으로 연결하기 위해 클릭이벤트를 사용했다. 생각보다 음성을 텍스트로 잘 변환시키고 잘 작동해서 만족했다.
결과물
버튼에 맞게 위에 뜨는 blink text와 녹음 중인지 녹음 전인지 표시하는 텍스트가 바뀌도록 구성했다.
맨 왼쪽에 위치한 마이크 아이콘을 눌러 이름을 설정하고 저장 버튼을 누른 뒤 rec 아이콘을 눌러 자유롭게 말할 수 있다.
여기서 내가 만약 "감자"라는 이름을 설정하고 "감자 안녕"이라는 문장을 말했을 때 "감자 안녕" 이라는 텍스트가 화면에 표시 된다.
이 팝업 페이지는 해커톤 하루 전에 만들기로 결정하고 정말 빨리 만든거라 발등튀김 된 채로 밤 새서 만들었던 기억이 난다.
근데 이게 배포를 하면 크롬에서 마이크 허용시키기 위해 어떤 복잡한 과정들을 거쳐야 되는데 .. 이 부분은 좀 불편한 것 같다 ....
그래도 로컬에서는 따로 설치할 필요 없이 코드만으로 작동 가능해서 다행이라 생각한다.
남은 방학동안 인공지능 공부 하고 영어 공부하고 해커톤 작업 마무리 하려고 한다. 다음 글은 연구실에서 공부했던 것들 정리를 할 예정 👩🏻💻