위 구조와 같이 음성 처리를 중점으로 온프레미스 환경에서 서비스 구축한다.
└─BACKEND-ARCHITECTURE
├─controllers 비즈니스 로직 구현
├─middlewares 라우터에 대한 미들웨어 구현
├─models DB 모델 구현
├─public public 리소스 저장
├─routes 라우링 구현
├─views view 코드
├─app.js app 환경설정을 위한 코드
└─server.js app listen을 위한 코드
npm i helmet hpp cross-env nodemon sanitize-html csurf winston pm2 morgan dotenv nunjucks
nodemon
node.js 애플리케이션을 자동으로 재시작하는 패키지
dotenv
.env에서 변수들을 불러올 수 있도록 하는 패키지
morgan
HTTP req logger middleware
winston
다중 transport를 위한 범용 logger
nunjucks
js를 위한 templating engine
cross-env
env 변수를 플랫폼에서 세팅하고 사용하는 패키지
pm2
built-in load balancer를 사용하는 프로덕션 프로세스 매니저
helmet
HTTP res header 보안을 위한 패키지
hpp
HTTP Parameter Pollution attack을 보호하기 위한 패키지
csurf
Cross-Site Request Forgery 방지 패키지
const express = require('express');
const path = require('path');
const morgan = require('morgan');
const helmet = require('helmet');
const dotenv = require('dotenv');
const nunjucks = require('nunjucks');
dotenv.config();
const indexRouter = require('./routes/index');
const app = express();
app.set('port', process.env.PORT || 3000);
app.set('view engine', 'html');
nunjucks.configure('views', {
express: app,
watch: true,
});
if (process.env.NODE_ENV === 'production') {
app.enable('trust proxy');
app.use(morgan('combined'));
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: false,
}),
);
app.use(hpp());
} else {
app.use(morgan('dev'));
}
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use('/', indexRouter);
app.use((req, res, next) => {
const error = new Error(`${req.method} ${req.url} Router doesn't exists`);
error.status = 404;
next(error);
});
app.use((err, req, res, next) => {
console.error(err);
res.locals.message = err.message;
res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
app.js에서 서비스를 위한 라우터 설정, 보안 설정들을 완료한다
const app = require('./app');
app.listen(app.get('port'), () => {
console.log('Server is listening at ', app.get('port'));
});
이후 모듈화된 app을 server.js에서 listen하여 req를 받는다
npm install socket.io
socket 통신을 위한 express socket.io 패키지를 설치한다.
const socketIO = require('socket.io');
function initSocket(server, app) {
console.log('Configuring Socket');
const io = socketIO(server, {
cors: {
credentials: true,
},
allowEIO3: true,
});
app.set('io', io);
io.on('connection', (clientSocket) => {
console.log('Client connected');
clientSocket.on('audioData', (data) => {
const recognitionResult = 'Recognition result from the server';
clientSocket.emit('recognitionResult', recognitionResult);
});
clientSocket.on('disconnect', () => {
console.log('Client disconnected');
});
});
}
module.exports = initSocket;
이후 socket.js에 위와 같이 socket 서비스를 구현한다.
clientSocket으로부터 요청이 들어오면 메세지로 변환하여 emit한다.
//Set Socket
const initSocket = require('./socket');
const http = require('http');
const server = http.createServer(app);
initSocket(server, app);
...
module.exports = app;
이후 위와 같이 socket을 init하여 connection을 대기하고 요청을 받는다.
...
io.on('connection', (clientSocket) => {
console.log('Socket Client connected');
clientSocket.on('audioData', (data) => {
console.log(data);
// Buffer Data to Audio
text = data;
// Speech top Text Logic
const recognitionResult = text;
clientSocket.emit('recognitionResult', recognitionResult);
});
clientSocket.on('disconnect', () => {
console.log('Socket Client disconnected');
});
});
}
아직은 인공지능 로직이 구현되어 있지 않기 때문에 주석으로 이를 표시한다.
현재는 ArrayBuffer를 그대로 돌려주어 통신을 확인할 수 있도록 한다.
<template>
<div class="background d-flex justify-content-center align-items-center vh-100">
<div class="card-container text-center">
<img src="@/assets/logo.png" alt="Logo" class="card-item logo-img mb-3" />
<button @click="startRecording" class="card-item btn btn-primary rounded-pill mb-2">음성 인식 시작</button>
<button @click="stopRecording" class="card-item btn btn-danger rounded-pill">음성 인식 중단</button>
</div>
</div>
</template>
<style>
body {
background-color: #121212;
margin: 0;
}
.background {
height: 100vh;
}
.card-container {
border-radius: 20px;
width: 30%;
height: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #1e1e1e;
}
.card-item {
margin: 15px;
}
.logo-img {
border-radius: 100px;
width: 150px;
}
.btn {
font-size: 20px;
border-radius: 10px;
background-color: #337ea9;
color: white;
}
</style>
yarn add socket.io-client
vue.js에서 socket을 사용할 수 있도록 패키지를 설치한다.
import { createApp } from 'vue';
import App from './App.vue';
import io from 'socket.io-client';
const socket = io(process.env.VUE_APP_BACKEND_LOCALHOST, {
transports: ["websocket"],
withCredentials: true,
});
const app = createApp(App);
app.provide('socket', socket);
app.config.productionTip = false;
app.mount('#app');
main.js
에서 위와 같이 app에 socket을 사용할 수 있도록 한다.
또한 withCredentials
를 설정하여 CORS 에러에 대응할 수 있도록 한다.
<script>
import { inject } from 'vue';
export default {
data() {
return {
socket: null,
...
};
},
methods: {
...
mounted() {
this.socket = inject('socket');
this.socket.on('recognitionResult', (result) => {
this.recognitionResult = result;
console.log(this.recognitionResult);
});
},
};
</script>
inject('socket')
으로 main.js에서 기존에 정의된 socket 객체를 가져온다.
<script>
import { inject } from 'vue';
export default {
data() {
return {
socket: null,
recognitionResult: '',
mediaRecorder: null,
isRecording: false,
logoImageSrc: require('@/assets/logo.png'),
};
},
methods: {
async startRecording() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.socket.emit('audioData', event.data);
}
};
this.mediaRecorder.onstop = () => {
stream.getTracks().forEach(track => track.stop());
this.isRecording = false;
};
this.mediaRecorder.start();
this.isRecording = true;
this.sendAudioDataInterval = setInterval(() => {
this.mediaRecorder.requestData();
}, 1000);
},
stopRecording() {
this.isRecording = false;
if (this.mediaRecorder.state === 'recording') {
this.mediaRecorder.stop();
}
clearInterval(this.sendAudioDataInterval);
},
},
mounted() {
this.socket = inject('socket');
this.socket.on('recognitionResult', (result) => {
const enc = new TextDecoder("utf-8");
this.recognitionResult = enc.decode(result);
});
},
};
</script>
...
.subtitles {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
color: #121212;
background-color: white;
font-size: 18px;
max-width: 300px;
max-height: 50px;
}
</style>
startRecording()
함수를 호출하면 setInterval
을 통해 1초에 한번씩 this.mediaRecorder.requestData();
를 호출하여 socket에 emit한다
최종적으로 subtitles
를 추가하고 현재는 enc.decode(result)
으로 인코딩한 BufferedArray를 화면에 노출시켜서 자막 기능을 구현하였다.