- vitest 41개 단위 테스트 (HistoryService, DictionaryService, CustomInstructionService, VoiceModeService, D3ROError) - electron-builder.yml (NSIS, asarUnpack, extraResources) - .gitlab-ci.yml (lint, typecheck, test, build, release) - SoundEffectService: WAV 프리로드 + PowerShell 재생 + VoiceMode 연동 - AutoLaunchService: app.setLoginItemSettings + ConfigService 동기화 - TextInsertService: 간이 삽입 검증 (EditMonitor 경량) - 번들링 인프라: SoX 다운로드 스크립트, PyInstaller 빌드, 경로 해상도 유틸 - AudioCaptureService/LocalSTTService: 번들 경로 자동 감지 - 08-design-system.md 기반 MUI 테마 (다크+라이트+auto 테마 시스템) - 전체 UI 컴포넌트 리디자인: AppLayout, Dashboard, StatusBar, History, Dictionary, Commands, Settings - 효과음 WAV 생성: recording-start, recording-stop, error - EPIPE 에러 핸들링 추가
147 lines
4.6 KiB
JavaScript
147 lines
4.6 KiB
JavaScript
/**
|
||
* D3RO-VOICE 효과음 WAV 파일 생성 스크립트
|
||
*
|
||
* 생성 파일:
|
||
* resources/sounds/recording-start.wav — 상승 톤 (440→880Hz, 150ms)
|
||
* resources/sounds/recording-stop.wav — 하강 톤 (880→440Hz, 150ms)
|
||
* resources/sounds/error.wav — 저음 비프 2회 (220Hz, 200ms×2)
|
||
*
|
||
* WAV 포맷: 16kHz, mono, 16bit PCM
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const SAMPLE_RATE = 16000;
|
||
const BIT_DEPTH = 16;
|
||
const NUM_CHANNELS = 1;
|
||
const BYTES_PER_SAMPLE = BIT_DEPTH / 8;
|
||
|
||
/**
|
||
* 사인파 샘플 생성 (주파수 선형 스윕 지원)
|
||
* @param {number} durationMs - 길이 (ms)
|
||
* @param {number} freqStart - 시작 주파수 (Hz)
|
||
* @param {number} freqEnd - 끝 주파수 (Hz)
|
||
* @param {number} volume - 볼륨 (0.0~1.0)
|
||
* @returns {Int16Array}
|
||
*/
|
||
function generateTone(durationMs, freqStart, freqEnd, volume = 0.6) {
|
||
const numSamples = Math.floor((SAMPLE_RATE * durationMs) / 1000);
|
||
const samples = new Int16Array(numSamples);
|
||
const maxVal = 32767 * volume;
|
||
|
||
// 페이드 인/아웃 길이 (클릭 방지)
|
||
const fadeSamples = Math.min(Math.floor(numSamples * 0.05), 80);
|
||
|
||
let phase = 0;
|
||
for (let i = 0; i < numSamples; i++) {
|
||
const t = i / numSamples;
|
||
const freq = freqStart + (freqEnd - freqStart) * t;
|
||
|
||
// 페이드 인/아웃 엔벨로프
|
||
let envelope = 1.0;
|
||
if (i < fadeSamples) {
|
||
envelope = i / fadeSamples;
|
||
} else if (i > numSamples - fadeSamples) {
|
||
envelope = (numSamples - i) / fadeSamples;
|
||
}
|
||
|
||
samples[i] = Math.round(Math.sin(phase) * maxVal * envelope);
|
||
phase += (2 * Math.PI * freq) / SAMPLE_RATE;
|
||
}
|
||
|
||
return samples;
|
||
}
|
||
|
||
/**
|
||
* 무음 생성
|
||
* @param {number} durationMs
|
||
* @returns {Int16Array}
|
||
*/
|
||
function generateSilence(durationMs) {
|
||
const numSamples = Math.floor((SAMPLE_RATE * durationMs) / 1000);
|
||
return new Int16Array(numSamples);
|
||
}
|
||
|
||
/**
|
||
* 여러 샘플 배열을 이어붙임
|
||
* @param {Int16Array[]} arrays
|
||
* @returns {Int16Array}
|
||
*/
|
||
function concatenate(arrays) {
|
||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||
const result = new Int16Array(totalLength);
|
||
let offset = 0;
|
||
for (const arr of arrays) {
|
||
result.set(arr, offset);
|
||
offset += arr.length;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* PCM 데이터를 WAV 파일 버퍼로 변환
|
||
* @param {Int16Array} samples
|
||
* @returns {Buffer}
|
||
*/
|
||
function createWavBuffer(samples) {
|
||
const dataSize = samples.length * BYTES_PER_SAMPLE;
|
||
const headerSize = 44;
|
||
const buffer = Buffer.alloc(headerSize + dataSize);
|
||
|
||
// RIFF header
|
||
buffer.write('RIFF', 0);
|
||
buffer.writeUInt32LE(headerSize - 8 + dataSize, 4);
|
||
buffer.write('WAVE', 8);
|
||
|
||
// fmt chunk
|
||
buffer.write('fmt ', 12);
|
||
buffer.writeUInt32LE(16, 16); // chunk size
|
||
buffer.writeUInt16LE(1, 20); // PCM format
|
||
buffer.writeUInt16LE(NUM_CHANNELS, 22);
|
||
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
||
buffer.writeUInt32LE(SAMPLE_RATE * NUM_CHANNELS * BYTES_PER_SAMPLE, 28); // byte rate
|
||
buffer.writeUInt16LE(NUM_CHANNELS * BYTES_PER_SAMPLE, 32); // block align
|
||
buffer.writeUInt16LE(BIT_DEPTH, 34);
|
||
|
||
// data chunk
|
||
buffer.write('data', 36);
|
||
buffer.writeUInt32LE(dataSize, 40);
|
||
|
||
// PCM data (Int16 little-endian)
|
||
const pcmBuffer = Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength);
|
||
pcmBuffer.copy(buffer, headerSize);
|
||
|
||
return buffer;
|
||
}
|
||
|
||
// --- 효과음 생성 ---
|
||
|
||
const outputDir = path.resolve(__dirname, '..', 'resources', 'sounds');
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
|
||
// 1. recording-start.wav: 상승 톤 440→880Hz, 150ms
|
||
const startSamples = generateTone(150, 440, 880, 0.5);
|
||
const startWav = createWavBuffer(startSamples);
|
||
const startPath = path.join(outputDir, 'recording-start.wav');
|
||
fs.writeFileSync(startPath, startWav);
|
||
console.log(`Created: ${startPath} (${startWav.length} bytes)`);
|
||
|
||
// 2. recording-stop.wav: 하강 톤 880→440Hz, 150ms
|
||
const stopSamples = generateTone(150, 880, 440, 0.5);
|
||
const stopWav = createWavBuffer(stopSamples);
|
||
const stopPath = path.join(outputDir, 'recording-stop.wav');
|
||
fs.writeFileSync(stopPath, stopWav);
|
||
console.log(`Created: ${stopPath} (${stopWav.length} bytes)`);
|
||
|
||
// 3. error.wav: 220Hz 비프 200ms × 2회, 중간 100ms 무음
|
||
const beep1 = generateTone(200, 220, 220, 0.5);
|
||
const gap = generateSilence(100);
|
||
const beep2 = generateTone(200, 220, 220, 0.5);
|
||
const errorSamples = concatenate([beep1, gap, beep2]);
|
||
const errorWav = createWavBuffer(errorSamples);
|
||
const errorPath = path.join(outputDir, 'error.wav');
|
||
fs.writeFileSync(errorPath, errorWav);
|
||
console.log(`Created: ${errorPath} (${errorWav.length} bytes)`);
|
||
|
||
console.log('\nDone! All 3 sound files generated.');
|