fix(sound): apply the Sound switch at once instead of after a restart
This commit is contained in:
parent
91d4b4974c
commit
06e96c58f4
3 changed files with 300 additions and 69 deletions
|
|
@ -1,15 +1,19 @@
|
||||||
// src/main/services/SoundEffectService.ts
|
// src/main/services/SoundEffectService.ts
|
||||||
// 녹음 시작/종료/에러/취소 효과음 재생. 설계서 01 ISoundEffectService 구현.
|
// 녹음 시작/종료/에러/취소 효과음 재생. 설계서 01 ISoundEffectService 구현.
|
||||||
// fire-and-forget 패턴, WAV 프리로드(메모리 캐싱).
|
// fire-and-forget 패턴, WAV 프리로드(메모리 캐싱).
|
||||||
|
//
|
||||||
|
// 의존성은 포트로 주입한다 (설정 저장소 / 파일 로더 / 재생기).
|
||||||
|
// 설정은 config-changed 이벤트를 구독해 Settings·클라우드 동기화 변경을 즉시 반영한다.
|
||||||
|
|
||||||
import { readFileSync, existsSync } from 'fs'
|
import { readFileSync, existsSync } from 'fs'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { configGet, configSet } from './ConfigService'
|
import { configGet, configSet, onConfigChanged } from './ConfigService'
|
||||||
import { getSoundPath } from '../utils/paths'
|
import { getSoundPath } from '../utils/paths'
|
||||||
|
import { playWavNative } from '../utils/nativeWavPlayer'
|
||||||
|
|
||||||
const logger = getLogger('SoundEffectService')
|
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> = {
|
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 응답 완료)
|
'chime': 'recording-stop.wav' // chime은 recording-stop 재사용 (Voice Conversation 응답 완료)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 프리로드된 WAV 바이너리 캐시 */
|
/** 효과음 on/off 설정 포트 (정본: ConfigService 'soundEnabled') */
|
||||||
const soundCache = new Map<SoundName, Buffer>()
|
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
|
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에서 호출.
|
* bootstrap에서 호출.
|
||||||
*/
|
*/
|
||||||
initialize(): void {
|
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)) {
|
for (const [name, filename] of Object.entries(SOUND_FILES)) {
|
||||||
const filePath = getSoundPath(filename)
|
const filePath = this.deps.output.resolvePath(filename)
|
||||||
if (existsSync(filePath)) {
|
|
||||||
try {
|
try {
|
||||||
const buffer = readFileSync(filePath)
|
const buffer = this.deps.output.load(filePath)
|
||||||
soundCache.set(name as SoundName, buffer)
|
if (buffer) {
|
||||||
|
this._cache.set(name as SoundName, buffer)
|
||||||
logger.debug(`Sound preloaded: ${name} (${buffer.length} bytes)`)
|
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 {
|
} else {
|
||||||
logger.debug(`Sound file not found: ${filePath}`)
|
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 {
|
play(sound: SoundName): void {
|
||||||
if (!this._enabled) return
|
if (!this._enabled) return
|
||||||
|
|
||||||
const buffer = soundCache.get(sound)
|
const buffer = this._cache.get(sound)
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
logger.debug(`Sound not cached, skipping: ${sound}`)
|
logger.debug(`Sound not cached, skipping: ${sound}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Electron의 renderer에서 재생하도록 IPC로 전달하는 대신,
|
// main process에서 직접 재생 (플랫폼 네이티브 플레이어)
|
||||||
// main process에서 직접 재생. node-wav-player 또는 child_process 사용.
|
this.deps.output.play(this.deps.output.resolvePath(SOUND_FILES[sound]))
|
||||||
// 가장 간단한 방법: PowerShell로 WAV 재생 (Windows)
|
|
||||||
this._playWavNative(getSoundPath(SOUND_FILES[sound]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnabled(enabled: boolean): void {
|
setEnabled(enabled: boolean): void {
|
||||||
this._enabled = enabled
|
this._enabled = enabled
|
||||||
configSet('soundEnabled', enabled)
|
this.deps.settings.write(enabled)
|
||||||
logger.info(`Sound effects ${enabled ? 'enabled' : 'disabled'}`)
|
logger.info(`Sound effects ${enabled ? 'enabled' : 'disabled'}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,54 +128,11 @@ class SoundEffectService {
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
soundCache.clear()
|
this._unsubscribeSettings?.()
|
||||||
|
this._unsubscribeSettings = null
|
||||||
|
this._cache.clear()
|
||||||
logger.info('SoundEffectService disposed')
|
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)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 싱글톤 ──
|
// ── 싱글톤 ──
|
||||||
|
|
|
||||||
50
apps/desktop/src/main/utils/nativeWavPlayer.ts
Normal file
50
apps/desktop/src/main/utils/nativeWavPlayer.ts
Normal 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)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue