d3ro-voice/scripts/generate-sounds.js
Yun Chan 3f4d0c5828 Phase 7~8 구현: 테스트 + 빌드 + CI/CD + SoundEffect + AutoLaunch + UI 리디자인
- 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 에러 핸들링 추가
2026-04-05 09:12:56 +09:00

147 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.');