// src/main/services/SoundEffectService.ts // 녹음 시작/종료/에러/취소 효과음 재생. 설계서 01 ISoundEffectService 구현. // fire-and-forget 패턴, WAV 프리로드(메모리 캐싱). import { readFileSync, existsSync } from 'fs' import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' import { getSoundPath } from '../utils/paths' const logger = getLogger('SoundEffectService') type SoundName = 'recording-start' | 'recording-stop' | 'error' | 'cancel' | 'chime' /** 효과음 파일 매핑 */ const SOUND_FILES: Record = { 'recording-start': 'recording-start.wav', 'recording-stop': 'recording-stop.wav', 'error': 'error.wav', 'cancel': 'error.wav', // cancel은 error와 동일 'chime': 'recording-stop.wav' // chime은 recording-stop 재사용 (Voice Conversation 응답 완료) } /** 프리로드된 WAV 바이너리 캐시 */ const soundCache = new Map() class SoundEffectService { private _enabled = true /** * 효과음 파일을 메모리에 프리로드한다. * bootstrap에서 호출. */ initialize(): void { this._enabled = configGet('soundEnabled') for (const [name, filename] of Object.entries(SOUND_FILES)) { const filePath = getSoundPath(filename) if (existsSync(filePath)) { try { const buffer = readFileSync(filePath) soundCache.set(name as SoundName, buffer) logger.debug(`Sound preloaded: ${name} (${buffer.length} bytes)`) } catch (err) { logger.warn(`Failed to preload sound ${name}: ${err instanceof Error ? err.message : String(err)}`) } } else { logger.debug(`Sound file not found: ${filePath}`) } } logger.info(`SoundEffectService initialized (${soundCache.size} sounds cached, enabled: ${this._enabled})`) } /** * 효과음 재생 (fire-and-forget). * 비활성 상태면 무시. 캐시에 없으면 무시. */ play(sound: SoundName): void { if (!this._enabled) return const buffer = soundCache.get(sound) if (!buffer) { logger.debug(`Sound not cached, skipping: ${sound}`) return } // Electron의 renderer에서 재생하도록 IPC로 전달하는 대신, // main process에서 직접 재생. node-wav-player 또는 child_process 사용. // 가장 간단한 방법: PowerShell로 WAV 재생 (Windows) this._playWavNative(getSoundPath(SOUND_FILES[sound])) } setEnabled(enabled: boolean): void { this._enabled = enabled configSet('soundEnabled', enabled) logger.info(`Sound effects ${enabled ? 'enabled' : 'disabled'}`) } isEnabled(): boolean { return this._enabled } dispose(): void { soundCache.clear() logger.info('SoundEffectService disposed') } /** * 플랫폼별 네이티브 WAV 재생 (비동기, fire-and-forget). * - Windows: PowerShell SoundPlayer * - macOS: /usr/bin/afplay * - Linux: aplay (alsa-utils, 대부분 기본 설치) */ private _playWavNative(filePath: string): void { if (!existsSync(filePath)) return try { const { exec } = require('child_process') as typeof import('child_process') if (process.platform === 'win32') { const escapedPath = filePath.replace(/'/g, "''") exec( `powershell -NoProfile -Command "(New-Object Media.SoundPlayer '${escapedPath}').PlaySync()"`, { windowsHide: true }, (err: Error | null) => { if (err) { logger.debug(`Sound play failed: ${err.message}`) } } ) } else if (process.platform === 'darwin') { // macOS: afplay는 기본 포함, 쉘 인젝션 방지를 위해 execFile 사용 const { execFile } = require('child_process') as typeof import('child_process') execFile('/usr/bin/afplay', [filePath], (err: Error | null) => { if (err) { logger.debug(`afplay failed: ${err.message}`) } }) } else { // Linux: aplay fallback const { execFile } = require('child_process') as typeof import('child_process') execFile('aplay', ['-q', filePath], (err: Error | null) => { if (err) { logger.debug(`aplay failed: ${err.message}`) } }) } } catch (err) { logger.debug(`Sound play error: ${err instanceof Error ? err.message : String(err)}`) } } } // ── 싱글톤 ── let instance: SoundEffectService | null = null export function getSoundEffectService(): SoundEffectService { if (!instance) { instance = new SoundEffectService() } return instance }