From 06e96c58f49b43766bf5c9a60df9316b5b012d10 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:53:47 +0900 Subject: [PATCH] fix(sound): apply the Sound switch at once instead of after a restart --- .../src/main/services/SoundEffectService.ts | 142 +++++++------- .../desktop/src/main/utils/nativeWavPlayer.ts | 50 +++++ .../SoundEffectService-redteam-r1-15.test.ts | 177 ++++++++++++++++++ 3 files changed, 300 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/src/main/utils/nativeWavPlayer.ts create mode 100644 apps/desktop/tests/main/services/SoundEffectService-redteam-r1-15.test.ts diff --git a/apps/desktop/src/main/services/SoundEffectService.ts b/apps/desktop/src/main/services/SoundEffectService.ts index f180c88..9f5c838 100644 --- a/apps/desktop/src/main/services/SoundEffectService.ts +++ b/apps/desktop/src/main/services/SoundEffectService.ts @@ -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 = { @@ -20,35 +24,80 @@ const SOUND_FILES: Record = { 'chime': 'recording-stop.wav' // chime은 recording-stop 재사용 (Voice Conversation 응답 완료) } -/** 프리로드된 WAV 바이너리 캐시 */ -const soundCache = new Map() +/** 효과음 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() + 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)}`) - } - } } // ── 싱글톤 ── diff --git a/apps/desktop/src/main/utils/nativeWavPlayer.ts b/apps/desktop/src/main/utils/nativeWavPlayer.ts new file mode 100644 index 0000000..596dbb8 --- /dev/null +++ b/apps/desktop/src/main/utils/nativeWavPlayer.ts @@ -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)}`) + } +} diff --git a/apps/desktop/tests/main/services/SoundEffectService-redteam-r1-15.test.ts b/apps/desktop/tests/main/services/SoundEffectService-redteam-r1-15.test.ts new file mode 100644 index 0000000..1cf737d --- /dev/null +++ b/apps/desktop/tests/main/services/SoundEffectService-redteam-r1-15.test.ts @@ -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() + 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) + }) +})