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
|
||||
// 녹음 시작/종료/에러/취소 효과음 재생. 설계서 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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
|
|
|
|||
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)}`)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue