fix(sound): apply the Sound switch at once instead of after a restart

This commit is contained in:
Yun Chan 2026-09-28 00:53:47 +09:00
parent 91d4b4974c
commit 06e96c58f4
3 changed files with 300 additions and 69 deletions

View file

@ -1,15 +1,19 @@
// src/main/services/SoundEffectService.ts
// 녹음 시작/종료/에러/취소 효과음 재생. 설계서 01 ISoundEffectService 구현.
// fire-and-forget 패턴, WAV 프리로드(메모리 캐싱).
//
// 의존성은 포트로 주입한다 (설정 저장소 / 파일 로더 / 재생기).
// 설정은 config-changed 이벤트를 구독해 Settings·클라우드 동기화 변경을 즉시 반영한다.
import { readFileSync, existsSync } from 'fs'
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import { configGet, configSet, onConfigChanged } from './ConfigService'
import { getSoundPath } from '../utils/paths'
import { playWavNative } from '../utils/nativeWavPlayer'
const logger = getLogger('SoundEffectService')
type SoundName = 'recording-start' | 'recording-stop' | 'error' | 'cancel' | 'chime'
export type SoundName = 'recording-start' | 'recording-stop' | 'error' | 'cancel' | 'chime'
/** 효과음 파일 매핑 */
const SOUND_FILES: Record<SoundName, string> = {
@ -20,35 +24,80 @@ const SOUND_FILES: Record<SoundName, string> = {
'chime': 'recording-stop.wav' // chime은 recording-stop 재사용 (Voice Conversation 응답 완료)
}
/** 프리로드된 WAV 바이너리 캐시 */
const soundCache = new Map<SoundName, Buffer>()
/** 효과음 on/off 설정 포트 (정본: ConfigService 'soundEnabled') */
export interface SoundSettingsPort {
read(): boolean
write(enabled: boolean): void
/** 외부 경로(설정 화면, 동기화)에서 값이 바뀌면 호출된다. 구독 해제 함수를 반환한다. */
subscribe(listener: (enabled: boolean) => void): () => void
}
class SoundEffectService {
/** 효과음 파일 IO + 재생 포트 */
export interface SoundOutputPort {
resolvePath(filename: string): string
load(filePath: string): Buffer | null
play(filePath: string): void
}
export interface SoundEffectDeps {
settings: SoundSettingsPort
output: SoundOutputPort
}
const configSoundSettings: SoundSettingsPort = {
read: () => configGet('soundEnabled'),
write: (enabled) => configSet('soundEnabled', enabled),
subscribe: (listener) =>
onConfigChanged((event) => {
if (event.key === 'soundEnabled' && typeof event.value === 'boolean') {
listener(event.value)
}
})
}
const fileSoundOutput: SoundOutputPort = {
resolvePath: getSoundPath,
load: (filePath) => (existsSync(filePath) ? readFileSync(filePath) : null),
play: playWavNative
}
export class SoundEffectService {
private _enabled = true
/** 프리로드된 WAV 바이너리 캐시 */
private readonly _cache = new Map<SoundName, Buffer>()
private _unsubscribeSettings: (() => void) | null = null
constructor(private readonly deps: SoundEffectDeps = { settings: configSoundSettings, output: fileSoundOutput }) {}
/**
* 효과음 파일을 메모리에 프리로드한다.
* 효과음 파일을 메모리에 프리로드하고 설정 변경을 구독한다.
* bootstrap에서 호출.
*/
initialize(): void {
this._enabled = configGet('soundEnabled')
this._enabled = this.deps.settings.read()
this._unsubscribeSettings?.()
this._unsubscribeSettings = this.deps.settings.subscribe((enabled) => {
if (enabled === this._enabled) return
this._enabled = enabled
logger.info(`Sound effects ${enabled ? 'enabled' : 'disabled'} (config changed)`)
})
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)
const filePath = this.deps.output.resolvePath(filename)
try {
const buffer = this.deps.output.load(filePath)
if (buffer) {
this._cache.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}`)
}
} else {
logger.debug(`Sound file not found: ${filePath}`)
} catch (err) {
logger.warn(`Failed to preload sound ${name}: ${err instanceof Error ? err.message : String(err)}`)
}
}
logger.info(`SoundEffectService initialized (${soundCache.size} sounds cached, enabled: ${this._enabled})`)
logger.info(`SoundEffectService initialized (${this._cache.size} sounds cached, enabled: ${this._enabled})`)
}
/**
@ -58,21 +107,19 @@ class SoundEffectService {
play(sound: SoundName): void {
if (!this._enabled) return
const buffer = soundCache.get(sound)
const buffer = this._cache.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]))
// main process에서 직접 재생 (플랫폼 네이티브 플레이어)
this.deps.output.play(this.deps.output.resolvePath(SOUND_FILES[sound]))
}
setEnabled(enabled: boolean): void {
this._enabled = enabled
configSet('soundEnabled', enabled)
this.deps.settings.write(enabled)
logger.info(`Sound effects ${enabled ? 'enabled' : 'disabled'}`)
}
@ -81,54 +128,11 @@ class SoundEffectService {
}
dispose(): void {
soundCache.clear()
this._unsubscribeSettings?.()
this._unsubscribeSettings = null
this._cache.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)}`)
}
}
}
// ── 싱글톤 ──

View file

@ -0,0 +1,50 @@
// src/main/utils/nativeWavPlayer.ts
// 플랫폼별 네이티브 WAV 재생 (비동기, fire-and-forget).
// SoundEffectService에서 분리한 재생 어댑터 — 서비스는 "무엇을 언제" 재생할지만 결정한다.
import { existsSync } from 'fs'
import { exec, execFile } from 'child_process'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('NativeWavPlayer')
/**
* WAV 파일을 비동기로 재생한다. 실패는 debug 로그만 남긴다.
* - Windows: PowerShell SoundPlayer
* - macOS: /usr/bin/afplay
* - Linux: aplay (alsa-utils, 대부분 기본 설치)
*/
export function playWavNative(filePath: string): void {
if (!existsSync(filePath)) return
try {
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 사용
execFile('/usr/bin/afplay', [filePath], (err: Error | null) => {
if (err) {
logger.debug(`afplay failed: ${err.message}`)
}
})
} else {
// Linux: aplay fallback
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)}`)
}
}