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)}`)
}
}

View file

@ -0,0 +1,177 @@
// tests/main/services/SoundEffectService-redteam-r1-15.test.ts
// 회귀: Settings에서 효과음을 끄면(config.set → configSet) 재시작 없이 즉시 반영되어야 한다.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
const SOUND_DIR = path.join(os.tmpdir(), 'd3ro-vitest-sounds-r1-15')
const { execMock, execFileMock } = vi.hoisted(() => ({
execMock: vi.fn(),
execFileMock: vi.fn()
}))
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>()
return { ...actual, exec: execMock, execFile: execFileMock }
})
vi.mock('../../../src/main/utils/paths', () => ({
getSoundPath: (filename: string) => path.join(SOUND_DIR, filename)
}))
import {
configSet,
initInMemoryConfig,
resetInMemoryConfig
} from '../../../src/main/services/ConfigService'
import {
SoundEffectService,
getSoundEffectService,
type SoundEffectDeps
} from '../../../src/main/services/SoundEffectService'
function playCount(): number {
return execMock.mock.calls.length + execFileMock.mock.calls.length
}
beforeAll(() => {
fs.mkdirSync(SOUND_DIR, { recursive: true })
for (const name of ['recording-start.wav', 'recording-stop.wav', 'error.wav']) {
fs.writeFileSync(path.join(SOUND_DIR, name), Buffer.from('RIFF0000WAVE'))
}
})
describe('SoundEffectService — soundEnabled 변경 즉시 반영 (singleton + ConfigService)', () => {
beforeEach(() => {
execMock.mockReset()
execFileMock.mockReset()
initInMemoryConfig({ soundEnabled: true })
})
afterEach(() => {
getSoundEffectService().dispose()
resetInMemoryConfig()
})
it('Settings가 configSet으로 soundEnabled=false를 쓰면 다음 play()는 재생하지 않는다', () => {
const service = getSoundEffectService()
service.initialize()
expect(service.isEnabled()).toBe(true)
// SettingsModal → config:set 핸들러 → configSet 경로 (setEnabled를 거치지 않음)
configSet('soundEnabled', false)
expect(service.isEnabled()).toBe(false)
service.play('recording-start')
service.play('error')
expect(playCount()).toBe(0)
// 다시 켜면 곧바로 재생된다 (동기화로 들어온 값도 같은 경로)
configSet('soundEnabled', true)
expect(service.isEnabled()).toBe(true)
service.play('recording-stop')
expect(playCount()).toBe(1)
})
it('dispose 후에는 config 변경을 더 이상 구독하지 않는다', () => {
const service = getSoundEffectService()
service.initialize()
service.dispose()
configSet('soundEnabled', false)
expect(service.isEnabled()).toBe(true)
})
})
describe('SoundEffectService — 포트 주입 단위 테스트', () => {
function createDeps(initial: boolean): {
deps: SoundEffectDeps
emit: (enabled: boolean) => void
listenerCount: () => number
played: string[]
written: boolean[]
} {
const listeners = new Set<(enabled: boolean) => void>()
const played: string[] = []
const written: boolean[] = []
let value = initial
const deps: SoundEffectDeps = {
settings: {
read: () => value,
write: (enabled) => {
value = enabled
written.push(enabled)
listeners.forEach((l) => l(enabled))
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
},
output: {
resolvePath: (filename) => `/sounds/${filename}`,
load: (filePath) => (filePath.endsWith('error.wav') ? null : Buffer.from('wav')),
play: (filePath) => played.push(filePath)
}
}
return {
deps,
emit: (enabled) => {
value = enabled
listeners.forEach((l) => l(enabled))
},
listenerCount: () => listeners.size,
played,
written
}
}
it('초기값을 설정 포트에서 읽고, 외부 변경을 따라간다', () => {
const h = createDeps(false)
const service = new SoundEffectService(h.deps)
service.initialize()
service.play('recording-start')
expect(h.played).toEqual([])
h.emit(true)
service.play('recording-start')
service.play('chime')
expect(h.played).toEqual(['/sounds/recording-start.wav', '/sounds/recording-stop.wav'])
})
it('로드되지 않은 효과음은 건너뛴다', () => {
const h = createDeps(true)
const service = new SoundEffectService(h.deps)
service.initialize()
service.play('error')
service.play('cancel')
expect(h.played).toEqual([])
})
it('setEnabled는 설정 포트에 기록하고 상태를 갱신한다', () => {
const h = createDeps(true)
const service = new SoundEffectService(h.deps)
service.initialize()
service.setEnabled(false)
expect(h.written).toEqual([false])
expect(service.isEnabled()).toBe(false)
service.play('recording-start')
expect(h.played).toEqual([])
})
it('initialize를 두 번 호출해도 구독은 하나만 유지되고 dispose가 해제한다', () => {
const h = createDeps(true)
const service = new SoundEffectService(h.deps)
service.initialize()
service.initialize()
expect(h.listenerCount()).toBe(1)
service.dispose()
expect(h.listenerCount()).toBe(0)
})
})