브라우저 기반 오디오 기록 시스템 구축
웹 애플리케이션 내에서 사용자의 음성을 수집하고 다양한 포맷으로 변환하여 저장하는 기능을 구현하는 과정입니다. 본 내용은 js-audio-recorder 라이브러리를 중심으로 Vue.js 프레임워크에서 녹음 컨트롤러를 관리하고, 캔버스 (Canvas) 를 통해 실시간 사운드 웨이브 형태를 표현하는 방법을 기술합니다.
1. 필수 의존성 설치
녹음 코어 엔진과 오디오 인코딩 처리를 위해 별도의 패키지를 준비해야 합니다.
- 오디오 레코더 모듈: 메인 녹음 로직 담당
- LAME 인코더: WAV 데이터를 MP3 형태로 전환할 때 사용
npm install js-audio-recorder lamejs --save
2. 컴포넌트 아키텍처 설계
Vue 단일 파일 컴포넌트 (SFC) 구조를 기반으로 하되, 상태 관리와 이벤트 핸들링을 명확히 분리합니다. 템플릿 영역에서는 사용자 인터랙션 버튼들을 배치하고, 스크립트 영역에서는 실제 비즈니스 로직을 처리합니다.
<template>
<div id="app-container">
<!-- 제어 패널 영역 -->
<div class="control-panel">
<button @click="requestMicAccess()" type="primary">마이크 접근권한 요청</button>
<button @click="initiateRecording()" :disabled="isRecording">녹화 시작</button>
<button @click="holdRecording()" :disabled="!isPaused">녹화 재개</button>
<button @click="sustainRecording()" :disabled="!isRecording">녹화 일시중지</button>
<button @click="finalizeRecording()" :disabled="!isRecording">녹화 종료</button>
<hr />
<button @click="playbackSound()" :disabled="!hasData">재생 시작</button>
<button @click="pausePlayback()" :disabled="!isPlaying">재생 정지</button>
<button @click="continuePlayback()" :disabled="!isPausedPlay">재생 복귀</button>
&div>
<!-- 데이터 처리 영역 -->
<div class="data-actions">
<button @click="downloadPcmFormat()">PCM 원본 다운로드</button>
<button @click="downloadWavFormat()">WAV 파일 다운로드</button>
<button @click="convertToMp3AndSave()">MP3 변환 및 저장</button>
<button @click="resetSystem()">시스템 초기화</button>
</div>
<!-- 시각화 영역 -->
<div class="visualization-area">
<canvas ref="recCanvas" width="800" height="200" />
<span>|</span>
<canvas ref="playCanvas" width="800" height="200" />
</div>
</div></template>
<script>
import RecorderEngine from 'js-audio-recorder';
import * as LameEncoder from 'lamejs';
export default {
name: 'AudioManagement',
data() {
return {
engineInstance: null,
isRecording: false,
isPaused: false,
isPlaying: false,
isPausedPlay: false,
hasData: false,
animationFrameId: null,
playAnimationFrameId: null,
recContext: null,
playContext: null,
};
},
mounted() {
this.setupVisualization();
this.initializeEngine();
},
beforeDestroy() {
this.cleanupResources();
},
methods: {
// 초기화 설정
initializeEngine() {
this.engineInstance = new RecorderEngine({
bitDepth: 16,
sampleFrequency: 44100, // 표준 CD 품질 샘플레이트
channelCount: 1 // 모노 채널 설정
});
// 녹음 진행 상황 모니터링 콜백
this.engineInstance.onprogress = ({ duration, vol }) => {
// console.log(`Duration: ${duration}s, Vol: ${vol}%`);
};
},
setupVisualization() {
const recEl = this.$refs.recCanvas;
const playEl = this.$refs.playCanvas;
this.recContext = recEl.getContext('2d');
this.playContext = playEl.getContext('2d');
},
// 마이크 권한 확인
requestMicAccess() {
RecorderEngine.getPermission().then(() => {
alert('마이크 접속 허용됨');
}).catch(err => console.error('권한 거부:', err));
},
// 녹음 로직 제어
initiateRecording() {
if (!this.engineInstance) return;
this.engineInstance.start()
.then(() => {
this.isRecording = true;
this.renderRecordingWaveform();
})
.catch(e => console.error(e));
},
holdRecording() {
if (this.engineInstance) this.engineInstance.resume();
this.isPaused = false;
this.isRecording = true;
this.renderRecordingWaveform();
},
sustainRecording() {
if (this.engineInstance) this.engineInstance.pause();
this.isPaused = true;
this.stopAnimation(this.animationFrameId);
},
finalizeRecording() {
if (this.engineInstance) {
this.engineInstance.stop();
this.hasData = true;
this.isRecording = false;
this.stopAnimation(this.animationFrameId);
}
},
// 재생 로직 제어
playbackSound() {
if (!this.engineInstance) return;
this.engineInstance.play();
this.isPlaying = true;
this.renderPlaybackWaveform();
},
pausePlayback() {
if (this.engineInstance) {
this.engineInstance.pausePlay();
this.isPausedPlay = true;
this.isPlaying = false;
this.stopAnimation(this.playAnimationFrameId);
}
},
continuePlayback() {
if (this.engineInstance) {
this.engineInstance.resumePlay();
this.isPausedPlay = false;
this.isPlaying = true;
this.renderPlaybackWaveform();
}
},
// 파형 렌더링 (녹음)
renderRecordingWaveform() {
if (this.animationFrameId) this.cancelAnimationFrame(this.animationFrameId);
const drawLoop = () => {
if (!this.isRecording || this.isPaused) return;
this.animationFrameId = requestAnimationFrame(drawLoop);
const analyserData = this.engineInstance.getRecordAnalyseData();
const length = analyserData.length;
const ctx = this.recContext;
const w = this.$refs.recCanvas.width;
const h = this.$refs.recCanvas.height;
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(0, 0, w, h);
ctx.beginPath();
ctx.lineWidth = 1.5;
ctx.strokeStyle = '#007aff';
const step = w / length;
for (let i = 0; i < length; i++) {
const v = analyserData[i] / 128.0;
const y = v * h / 2;
ctx.lineTo(i * step + h / 2, y); // 중앙 기준 그리기
}
ctx.stroke();
};
drawLoop();
},
// 파형 렌더링 (재생)
renderPlaybackWaveform() {
if (this.playAnimationFrameId) this.cancelAnimationFrame(this.playAnimationFrameId);
const drawLoop = () => {
if (!this.isPlaying || this.isPausedPlay) return;
this.playAnimationFrameId = requestAnimationFrame(drawLoop);
const analyserData = this.engineInstance.getPlayAnalyseData();
const length = analyserData.length;
const ctx = this.playContext;
const w = this.$refs.playCanvas.width;
const h = this.$refs.playCanvas.height;
ctx.clearRect(0, 0, w, h);
ctx.beginPath();
ctx.lineWidth = 1.5;
ctx.strokeStyle = '#ff3b30';
const step = w / length;
let x = 0;
for (let i = 0; i < length; i++) {
const v = analyserData[i] / 128.0;
const y = v * h / 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += step;
}
ctx.stroke();
};
drawLoop();
},
// 파일 처리 메서드
downloadPcmFormat() {
if (this.engineInstance) this.engineInstance.downloadPCM('raw_audio_data');
},
downloadWavFormat() {
if (this.engineInstance) this.engineInstance.downloadWAV('audio_capture');
},
convertToMp3AndSave() {
const rawWavBuffer = this.engineInstance.getWAV();
const mp3Blob = this.encodeToMp3(rawWavBuffer);
this.engineInstance.download(mp3Blob, 'compressed_audio', 'mp3');
},
encodeToMp3(wavBufferView) {
const wavHeaderInfo = LameEncoder.WavHeader.readHeader(wavBufferView);
const encoder = new LameEncoder.Mp3Encoder(wavHeaderInfo.channels, wavHeaderInfo.sampleRate, 128);
const channelsData = this.engineInstance.getChannelData();
const leftBuf = channelsData.left ? new Int16Array(channelsData.left.buffer) : null;
const rightBuf = channelsData.right ? new Int16Array(channelsData.right.buffer) : null;
const chunkSize = 1152;
const totalSamples = leftBuf ? leftBuf.length : 0;
const encodedChunks = [];
for (let offset = 0; offset < totalSamples; offset += chunkSize) {
const leftSlice = leftBuf.subarray(offset, offset + chunkSize);
const rightSlice = rightBuf ? rightBuf.subarray(offset, offset + chunkSize) : null;
if (wavHeaderInfo.channels === 2) {
encodedChunks.push(encoder.encodeBuffer(leftSlice, rightSlice));
} else {
encodedChunks.push(encoder.encodeBuffer(leftSlice));
}
}
const flushed = encoder.flush();
if (flushed.length > 0) encodedChunks.push(flushed);
return new Blob(encodedChunks, { type: 'audio/mpeg' });
},
resetSystem() {
if (this.engineInstance) {
this.engineInstance.destroy().then(() => {
this.engineInstance = null;
this.hasData = false;
});
}
this.cleanupResources();
},
cleanupResources() {
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
if (this.playAnimationFrameId) cancelAnimationFrame(this.playAnimationFrameId);
this.recContext.clearRect(0, 0, this.$refs.recCanvas.width, this.$refs.recCanvas.height);
this.playContext.clearRect(0, 0, this.$refs.playCanvas.width, this.$refs.playCanvas.height);
},
stopAnimation(id) {
if (id) {
cancelAnimationFrame(id);
this.animationFrameId = null;
}
}
}
};
</script>
3. 기술적 핵심 사항 설명
위 예제는 js-audio-recorder의 API 가 제공하는 기본 메서드를 감싸서 사용하기 좋은 래퍼 클래스 형태로 구성되었습니다. 주의할 점은 오디오 컨텍스트가 생성되고 난 후에도 지속적으로 메모리 관리를 수행해야 한다는 것입니다.
- 실시간 시각화:
requestAnimationFrame을 활용하여 화면 갱신 주기를 동기화함으로써 부드러운 웨이브 애니메이션을 구현했습니다. - 데이터 포맷 전환: 원본 PCM 또는 WAV 데이터는 용량이 크므로, 클라이언트 측에서
lamejs를 이용하여 압축된 MP3 스트림으로 변환 과정을 거칩니다. 이는 서버 업로드 시 대역폭 절감에 유리합니다. - 라이프사이클 관리: 컴포넌트가 마운트될 때 엔진을 초기화하고, 언마운트되거나 리셋될 때 메모리 누수를 방지하기 위해 인스턴스를 파괴 (
destroy) 하는 단계가 필수적입니다.
이제 사용자가 클릭 이벤트를 통해 녹음을 시작하면 화면상에 소리 진동이 실시간으로 그쳐지며, 작업을 마치면 변환된 파일을 브라우저 저장 dialog 를 통해 받아볼 수 있는 상태가 됩니다.