Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터

Phase 1:
- 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier)
- shared 타입 (ipc-channels 113채널, types, errors, constants)
- 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스)
- LoggerService, ConfigService (electron-store ESM dynamic import)
- React 19 + MUI 7 Dashboard, 시스템 트레이

Phase 2:
- AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono)
- HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode)
- LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시)
- VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue)
- Python sidecar (FastAPI: health/load/transcribe/shutdown)
- IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
Yun Chan 2026-04-05 02:01:37 +09:00
parent e24bb8378c
commit 1d152d01a1
46 changed files with 10828 additions and 4 deletions

View file

@ -0,0 +1,365 @@
// src/main/services/AudioCaptureService.ts
// 마이크 PCM 캡처 서비스. 설계서 01의 IAudioCaptureService 구현.
// node-record-lpcm16 + SoX로 PCM16 16kHz mono 캡처.
import { EventEmitter } from 'events'
import { record } from 'node-record-lpcm16'
import type { Recording } from 'node-record-lpcm16'
import type { Readable } from 'stream'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import type { AudioDevice } from '@shared/types'
import { AUDIO_FORMAT, TIMING } from '@shared/constants'
import { D3ROError, ErrorCode } from '@shared/errors'
const logger = getLogger('AudioCaptureService')
/** 60ms 프레임 크기 (바이트): 16000 * 2 * 0.06 = 1920 */
const FRAME_SIZE_BYTES = (AUDIO_FORMAT.SAMPLE_RATE * AUDIO_FORMAT.BYTES_PER_SAMPLE * 60) / 1000
export type CaptureState = 'idle' | 'starting' | 'capturing' | 'stopping' | 'error'
interface AudioCaptureEvents {
'audio-data': (payload: { buffer: Buffer; timestamp: number }) => void
'audio-level': (payload: { level: number; timestamp: number }) => void
'device-changed': (payload: {
previous: AudioDevice | null
current: AudioDevice
}) => void
started: (payload: { deviceId: string }) => void
stopped: (payload: { reason: 'manual' | 'device-lost' | 'error' }) => void
error: (payload: { error: D3ROError }) => void
}
/**
* PCM16 RMS(Root Mean Square) .
* 0.0 ~ 1.0 .
*/
function calculateRMS(buffer: Buffer): number {
const samples = buffer.length / AUDIO_FORMAT.BYTES_PER_SAMPLE
if (samples === 0) return 0
let sumSquares = 0
for (let i = 0; i < buffer.length; i += AUDIO_FORMAT.BYTES_PER_SAMPLE) {
const sample = buffer.readInt16LE(i)
sumSquares += sample * sample
}
const rms = Math.sqrt(sumSquares / samples)
// PCM16 최대값 32768로 나눠서 0.0~1.0 범위로 정규화
return Math.min(1.0, rms / 32768)
}
class AudioCaptureService extends EventEmitter {
private _state: CaptureState = 'idle'
private _currentDevice: AudioDevice | null = null
private _refCount = 0
private _recording: Recording | null = null
private _stream: Readable | null = null
private _levelInterval: ReturnType<typeof setInterval> | null = null
/** 프레임 조립용 잔여 바이트 버퍼 */
private _residualBuffer: Buffer = Buffer.alloc(0)
/** RMS 계산용 누적 버퍼 (100ms 간격 emit) */
private _levelAccumulator: Buffer[] = []
get state(): CaptureState {
return this._state
}
get currentDevice(): AudioDevice | null {
return this._currentDevice
}
async start(deviceId?: string): Promise<void> {
if (this._state === 'capturing') {
this._refCount++
logger.debug(`Reference count increased to ${this._refCount}`)
return
}
if (this._state !== 'idle' && this._state !== 'error') {
logger.warn(`Cannot start capture in state: ${this._state}`)
return
}
this._state = 'starting'
const selectedDeviceId = deviceId ?? configGet('selectedDeviceId')
try {
logger.info(
`Starting audio capture (device: ${selectedDeviceId ?? 'default'}, ` +
`format: ${AUDIO_FORMAT.SAMPLE_RATE}Hz ${AUDIO_FORMAT.CHANNELS}ch ${AUDIO_FORMAT.BIT_DEPTH}bit)`
)
// node-record-lpcm16 으로 SoX rec 프로세스 spawn
const recordingOptions: Record<string, unknown> = {
sampleRate: AUDIO_FORMAT.SAMPLE_RATE,
channels: AUDIO_FORMAT.CHANNELS,
recorder: 'sox',
audioType: 'raw', // 헤더 없는 PCM raw 출력
endOnSilence: false
}
// 특정 디바이스가 지정된 경우 AUDIODEV 환경변수로 전달
if (selectedDeviceId && selectedDeviceId !== 'default') {
recordingOptions.device = selectedDeviceId
}
this._recording = record(recordingOptions)
this._stream = this._recording.stream()
this._residualBuffer = Buffer.alloc(0)
this._levelAccumulator = []
// 스트림 데이터 수신: 60ms 프레임 단위로 잘라 emit
this._stream.on('data', (chunk: Buffer) => {
this._onAudioChunk(chunk)
})
// 스트림 에러 처리
this._stream.on('error', (errorMessage: string | Error) => {
const msg = typeof errorMessage === 'string' ? errorMessage : errorMessage.message
logger.error(`Audio stream error: ${msg}`)
// SoX 미설치 감지
const isSoxMissing =
msg.includes('ENOENT') ||
msg.includes('not found') ||
msg.includes('is not recognized')
const errorCode = isSoxMissing
? ErrorCode.AudioCaptureStartFailed
: ErrorCode.AudioStreamError
const d3roError = new D3ROError(
errorCode,
isSoxMissing
? 'SoX가 설치되어 있지 않거나 PATH에 없습니다. SoX를 설치해주세요: https://sox.sourceforge.net'
: `Audio stream error: ${msg}`
)
this._handleError(d3roError, 'error')
})
// SoX 프로세스 종료 감지
this._stream.on('end', () => {
if (this._state === 'capturing') {
logger.warn('Audio stream ended unexpectedly')
this._handleError(
new D3ROError(ErrorCode.AudioCaptureFailed, 'Audio capture process terminated unexpectedly'),
'device-lost'
)
}
})
// SoX child process 에러 이벤트 (spawn 실패 등)
if (this._recording.process) {
this._recording.process.on('error', (err: Error) => {
logger.error(`SoX process spawn error: ${err.message}`)
const d3roError = new D3ROError(
ErrorCode.AudioCaptureStartFailed,
`SoX 프로세스 시작 실패: ${err.message}. SoX가 설치되어 있는지 확인해주세요.`
)
this._handleError(d3roError, 'error')
})
}
this._currentDevice = {
deviceId: selectedDeviceId ?? 'default',
label: 'Default Microphone',
isDefault: !selectedDeviceId || selectedDeviceId === 'default'
}
this._state = 'capturing'
this._refCount = 1
// 100ms 간격으로 audio-level 이벤트 emit
this._levelInterval = setInterval(() => {
this._emitAudioLevel()
}, TIMING.AUDIO_LEVEL_INTERVAL)
this.emit('started', { deviceId: this._currentDevice.deviceId })
logger.info('Audio capture started')
} catch (error) {
this._state = 'error'
const d3roError = new D3ROError(
ErrorCode.AudioCaptureStartFailed,
`Failed to start audio capture: ${error instanceof Error ? error.message : String(error)}`
)
this.emit('error', { error: d3roError })
throw d3roError
}
}
async stop(): Promise<void> {
if (this._state !== 'capturing') {
return
}
this._refCount--
if (this._refCount > 0) {
logger.debug(`Reference count decreased to ${this._refCount}`)
return
}
this._state = 'stopping'
this._cleanup()
this._state = 'idle'
this._currentDevice = null
this.emit('stopped', { reason: 'manual' })
logger.info('Audio capture stopped')
}
async getDevices(): Promise<AudioDevice[]> {
// 현재 Phase에서는 기본 디바이스만 반환. 실제 디바이스 열거는 추후.
logger.debug('Getting audio devices (default only)')
return [
{
deviceId: 'default',
label: 'Default Microphone',
isDefault: true
}
]
}
getCurrentDevice(): AudioDevice | null {
return this._currentDevice
}
dispose(): void {
this._cleanup()
this._state = 'idle'
this._currentDevice = null
this._refCount = 0
this.removeAllListeners()
logger.info('AudioCaptureService disposed')
}
/**
* 60ms (1920 bytes) emit한다.
* .
*/
private _onAudioChunk(chunk: Buffer): void {
// 잔여 버퍼와 새 청크를 결합
const combined = this._residualBuffer.length > 0
? Buffer.concat([this._residualBuffer, chunk])
: chunk
let offset = 0
// 60ms 프레임 단위로 분할하여 emit
while (offset + FRAME_SIZE_BYTES <= combined.length) {
const frame = combined.subarray(offset, offset + FRAME_SIZE_BYTES)
offset += FRAME_SIZE_BYTES
this.emit('audio-data', {
buffer: Buffer.from(frame), // 방어적 복사
timestamp: Date.now()
})
// RMS 계산용 누적
this._levelAccumulator.push(frame)
}
// 남은 바이트는 잔여 버퍼에 보관
if (offset < combined.length) {
this._residualBuffer = Buffer.from(combined.subarray(offset))
} else {
this._residualBuffer = Buffer.alloc(0)
}
}
/**
* 100ms RMS emit한다.
*/
private _emitAudioLevel(): void {
if (this._levelAccumulator.length === 0) {
this.emit('audio-level', { level: 0, timestamp: Date.now() })
return
}
// 누적된 프레임들을 하나로 합쳐서 RMS 계산
const combined = Buffer.concat(this._levelAccumulator)
this._levelAccumulator = []
const level = calculateRMS(combined)
this.emit('audio-level', { level, timestamp: Date.now() })
}
/**
* + emit.
*/
private _handleError(error: D3ROError, stopReason: 'device-lost' | 'error'): void {
if (this._state === 'idle' || this._state === 'stopping') {
return
}
this._cleanup()
this._state = 'error'
this._currentDevice = null
this.emit('error', { error })
this.emit('stopped', { reason: stopReason })
}
/**
* .
*/
private _cleanup(): void {
if (this._levelInterval) {
clearInterval(this._levelInterval)
this._levelInterval = null
}
if (this._stream) {
this._stream.removeAllListeners()
this._stream = null
}
if (this._recording) {
try {
this._recording.stop()
} catch {
// 이미 종료된 프로세스 kill 시 에러 무시
}
this._recording = null
}
this._residualBuffer = Buffer.alloc(0)
this._levelAccumulator = []
}
// EventEmitter 타입 오버라이드
override on<K extends keyof AudioCaptureEvents>(
event: K,
listener: AudioCaptureEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof AudioCaptureEvents>(
event: K,
listener: AudioCaptureEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof AudioCaptureEvents>(
event: K,
...args: Parameters<AudioCaptureEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// 싱글톤
let instance: AudioCaptureService | null = null
export function getAudioCaptureService(): AudioCaptureService {
if (!instance) {
instance = new AudioCaptureService()
}
return instance
}

View file

@ -0,0 +1,119 @@
// src/main/services/ConfigService.ts
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
import { EventEmitter } from 'events'
import type { AppConfig, ConfigChangedEvent } from '@shared/types'
import { getLogger } from './LoggerService'
const logger = getLogger('ConfigService')
// electron-store v10은 ESM 전용이므로 동적 import 필요
interface ElectronStore<T extends Record<string, unknown>> {
get<K extends keyof T>(key: K): T[K]
set<K extends keyof T>(key: K, value: T[K]): void
store: T
}
const CONFIG_DEFAULTS: AppConfig = {
theme: 'auto',
language: 'ko',
closeToTray: true,
autoLaunch: false,
soundEnabled: true,
selectedDeviceId: null,
sttModelId: 'base',
sttLanguage: 'auto',
ttsVoiceId: null,
ttsSpeed: 1.0,
ollamaServerUrl: 'http://localhost:11434',
llmModelId: null,
defaultLLMAction: 'refine',
dictationShortcut: {
keyCode: 0xa5, // Right Alt
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt'
},
handsFreeShortcut: {
keyCode: 0xa5,
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt (double)'
},
commandShortcut: {
keyCode: 0xa5,
ctrl: true,
alt: false,
shift: false,
meta: false,
displayLabel: 'Ctrl + Right Alt'
},
hotkeyEnabled: true,
insertMethod: 'clipboard',
autoInsert: true,
maxHistoryEntries: 1000
}
let store: ElectronStore<AppConfig> | null = null
const emitter = new EventEmitter()
export async function initConfigService(): Promise<void> {
const { default: Store } = await import('electron-store')
store = new Store<AppConfig>({
name: 'd3ro-voice-config',
defaults: CONFIG_DEFAULTS
})
logger.info('ConfigService initialized')
}
export function getConfigService(): ElectronStore<AppConfig> | null {
return store
}
export function configGet<K extends keyof AppConfig>(key: K): AppConfig[K] {
if (!store) {
logger.warn(`ConfigService not initialized, returning default for "${key}"`)
return CONFIG_DEFAULTS[key]
}
return store.get(key)
}
export function configSet<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void {
if (!store) {
logger.warn(`ConfigService not initialized, cannot set "${key}"`)
return
}
const previousValue = store.get(key)
store.set(key, value)
const event: ConfigChangedEvent = {
key,
value,
previousValue
}
emitter.emit('config-changed', event)
logger.debug(`Config changed: ${key}`)
}
export function configGetAll(): AppConfig {
if (!store) return { ...CONFIG_DEFAULTS }
return store.store
}
export function configReset(key?: keyof AppConfig): void {
if (!store) return
if (key) {
store.set(key, CONFIG_DEFAULTS[key])
} else {
store.store = { ...CONFIG_DEFAULTS }
}
}
export function onConfigChanged(callback: (event: ConfigChangedEvent) => void): () => void {
emitter.on('config-changed', callback)
return () => emitter.off('config-changed', callback)
}

View file

@ -0,0 +1,536 @@
// src/main/services/HotkeyService.ts
// uiohook-napi 기반 글로벌 키보드 후킹 서비스.
// 설계서 01의 IHotkeyService 구현. Speakly HotkeyService + HotkeyConfig 패턴 참조.
import { EventEmitter } from 'events'
import { uIOhook, UiohookKey } from 'uiohook-napi'
import type { UiohookKeyboardEvent } from 'uiohook-napi'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import { TIMING } from '@shared/constants'
import type { HotkeyBinding } from '@shared/types'
const logger = getLogger('HotkeyService')
// ============================================================
// 내부 타입
// ============================================================
export interface HotkeyConfig {
/** 핫키 식별자 (예: 'voice-dictation', 'voice-handsfree') */
id: string
/** uiohook 키코드 */
keyCode: number
/** 수정자 키 목록 */
modifiers: HotkeyModifier[]
/** true=hold-to-talk (누르고 있는 동안 활성), false=toggle */
holdMode: boolean
/** 더블프레스 감지 활성화 여부 */
doublePressEnabled: boolean
/** 활성화 여부 */
enabled: boolean
}
export type HotkeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta'
interface HotkeyServiceEvents {
'hotkey-pressed': (payload: { config: HotkeyConfig; timestamp: number }) => void
'hotkey-released': (payload: {
config: HotkeyConfig
durationMs: number
timestamp: number
}) => void
'double-press': (payload: {
config: HotkeyConfig
intervalMs: number
timestamp: number
}) => void
error: (payload: { error: D3ROError }) => void
}
// ============================================================
// Windows VK 코드 → uiohook 키코드 매핑
// ============================================================
/**
* Windows Virtual-Key uiohook-napi .
* ConfigService에 HotkeyBinding.keyCode는 Windows VK
* uiohook .
*/
const VK_TO_UIOHOOK: ReadonlyMap<number, number> = new Map([
// 수정자 키
[0xa0, UiohookKey.Shift], // VK_LSHIFT
[0xa1, UiohookKey.ShiftRight], // VK_RSHIFT
[0xa2, UiohookKey.Ctrl], // VK_LCONTROL
[0xa3, UiohookKey.CtrlRight], // VK_RCONTROL
[0xa4, UiohookKey.Alt], // VK_LMENU
[0xa5, UiohookKey.AltRight], // VK_RMENU (Right Alt)
[0x5b, UiohookKey.Meta], // VK_LWIN
[0x5c, UiohookKey.MetaRight], // VK_RWIN
// 기능 키
[0x70, UiohookKey.F1],
[0x71, UiohookKey.F2],
[0x72, UiohookKey.F3],
[0x73, UiohookKey.F4],
[0x74, UiohookKey.F5],
[0x75, UiohookKey.F6],
[0x76, UiohookKey.F7],
[0x77, UiohookKey.F8],
[0x78, UiohookKey.F9],
[0x79, UiohookKey.F10],
[0x7a, UiohookKey.F11],
[0x7b, UiohookKey.F12],
// 일반 키
[0x20, UiohookKey.Space],
[0x0d, UiohookKey.Enter],
[0x1b, UiohookKey.Escape],
[0x08, UiohookKey.Backspace],
[0x09, UiohookKey.Tab],
[0x2d, UiohookKey.Insert],
[0x2e, UiohookKey.Delete],
[0x24, UiohookKey.Home],
[0x23, UiohookKey.End],
[0x21, UiohookKey.PageUp],
[0x22, UiohookKey.PageDown],
[0x25, UiohookKey.ArrowLeft],
[0x26, UiohookKey.ArrowUp],
[0x27, UiohookKey.ArrowRight],
[0x28, UiohookKey.ArrowDown],
// 알파벳 (VK_A=0x41 ~ VK_Z=0x5A)
[0x41, UiohookKey.A],
[0x42, UiohookKey.B],
[0x43, UiohookKey.C],
[0x44, UiohookKey.D],
[0x45, UiohookKey.E],
[0x46, UiohookKey.F],
[0x47, UiohookKey.G],
[0x48, UiohookKey.H],
[0x49, UiohookKey.I],
[0x4a, UiohookKey.J],
[0x4b, UiohookKey.K],
[0x4c, UiohookKey.L],
[0x4d, UiohookKey.M],
[0x4e, UiohookKey.N],
[0x4f, UiohookKey.O],
[0x50, UiohookKey.P],
[0x51, UiohookKey.Q],
[0x52, UiohookKey.R],
[0x53, UiohookKey.S],
[0x54, UiohookKey.T],
[0x55, UiohookKey.U],
[0x56, UiohookKey.V],
[0x57, UiohookKey.W],
[0x58, UiohookKey.X],
[0x59, UiohookKey.Y],
[0x5a, UiohookKey.Z],
// 숫자 (VK_0=0x30 ~ VK_9=0x39)
[0x30, UiohookKey['0']],
[0x31, UiohookKey['1']],
[0x32, UiohookKey['2']],
[0x33, UiohookKey['3']],
[0x34, UiohookKey['4']],
[0x35, UiohookKey['5']],
[0x36, UiohookKey['6']],
[0x37, UiohookKey['7']],
[0x38, UiohookKey['8']],
[0x39, UiohookKey['9']]
])
/**
* HotkeyBinding(Windows VK ) HotkeyConfig(uiohook ) .
*/
function bindingToConfig(
id: string,
binding: HotkeyBinding,
holdMode: boolean,
doublePressEnabled: boolean
): HotkeyConfig {
const uiohookKeyCode = VK_TO_UIOHOOK.get(binding.keyCode)
if (uiohookKeyCode === undefined) {
logger.warn(
`Unknown VK code 0x${binding.keyCode.toString(16)} for hotkey "${id}", ` +
`using raw value ${binding.keyCode}`
)
}
const modifiers: HotkeyModifier[] = []
if (binding.ctrl) modifiers.push('ctrl')
if (binding.alt) modifiers.push('alt')
if (binding.shift) modifiers.push('shift')
if (binding.meta) modifiers.push('meta')
return {
id,
keyCode: uiohookKeyCode ?? binding.keyCode,
modifiers,
holdMode,
doublePressEnabled,
enabled: true
}
}
// ============================================================
// HotkeyService 클래스
// ============================================================
class HotkeyService extends EventEmitter {
private _isRunning = false
private _registeredHotkeys: Map<string, HotkeyConfig> = new Map()
/** 더블프레스 감지용: 마지막 press 시각 */
private _lastPressTime: Map<string, number> = new Map()
/** hold duration 계산용: press 시작 시각 */
private _pressStartTime: Map<string, number> = new Map()
/** 키 반복(auto-repeat) 방지: 현재 눌려있는 키 */
private _isKeyDown: Map<string, boolean> = new Map()
/** uiohook 이벤트 핸들러 (바인딩 해제용) */
private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null
private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null
get isRunning(): boolean {
return this._isRunning
}
get registeredHotkeys(): ReadonlyMap<string, HotkeyConfig> {
return this._registeredHotkeys
}
/**
* uiohook .
* .
*/
start(): void {
if (this._isRunning) {
logger.warn('HotkeyService already running')
return
}
try {
this._onKeyDown = (e: UiohookKeyboardEvent) => this._handleKeyDown(e)
this._onKeyUp = (e: UiohookKeyboardEvent) => this._handleKeyUp(e)
uIOhook.on('keydown', this._onKeyDown)
uIOhook.on('keyup', this._onKeyUp)
uIOhook.start()
this._isRunning = true
logger.info('uiohook started, global keyboard hook active')
} catch (error) {
const d3roError = new D3ROError(
ErrorCode.HotkeyHookInitFailed,
`Failed to start uiohook: ${error instanceof Error ? error.message : String(error)}`
)
this.emit('error', { error: d3roError })
logger.error(d3roError.message)
}
}
/**
* uiohook .
*/
stop(): void {
if (!this._isRunning) {
return
}
try {
uIOhook.stop()
if (this._onKeyDown) {
uIOhook.removeListener('keydown', this._onKeyDown)
this._onKeyDown = null
}
if (this._onKeyUp) {
uIOhook.removeListener('keyup', this._onKeyUp)
this._onKeyUp = null
}
this._isRunning = false
this._isKeyDown.clear()
this._pressStartTime.clear()
logger.info('uiohook stopped')
} catch (error) {
logger.error(
`Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}`
)
}
}
/**
* . ID가 .
*/
registerHotkey(config: HotkeyConfig): void {
if (!config.enabled) {
logger.debug(`Hotkey "${config.id}" is disabled, skipping registration`)
return
}
this._registeredHotkeys.set(config.id, config)
logger.info(
`Hotkey registered: "${config.id}" ` +
`(keyCode=${config.keyCode}, modifiers=[${config.modifiers.join(',')}], ` +
`holdMode=${config.holdMode}, doublePress=${config.doublePressEnabled})`
)
}
/**
* .
*/
unregisterHotkey(id: string): void {
if (this._registeredHotkeys.delete(id)) {
this._lastPressTime.delete(id)
this._pressStartTime.delete(id)
this._isKeyDown.delete(id)
logger.info(`Hotkey unregistered: "${id}"`)
}
}
/**
* ConfigService에서 .
*/
loadFromConfig(): void {
const hotkeyEnabled = configGet('hotkeyEnabled')
if (!hotkeyEnabled) {
logger.info('Hotkeys disabled in config')
return
}
const dictationBinding = configGet('dictationShortcut')
const handsFreeBinding = configGet('handsFreeShortcut')
const commandBinding = configGet('commandShortcut')
// 기존 핫키 초기화
this._registeredHotkeys.clear()
// Dictation: hold-to-talk, 더블프레스 비활성
this.registerHotkey(
bindingToConfig('voice-dictation', dictationBinding, true, false)
)
// Hands-free: toggle, 더블프레스 활성
this.registerHotkey(
bindingToConfig('voice-handsfree', handsFreeBinding, false, true)
)
// Command: toggle, 더블프레스 비활성
this.registerHotkey(
bindingToConfig('voice-command', commandBinding, false, false)
)
logger.info(
`Loaded ${this._registeredHotkeys.size} hotkeys from config`
)
}
/**
* .
*/
dispose(): void {
this.stop()
this._registeredHotkeys.clear()
this._lastPressTime.clear()
this._pressStartTime.clear()
this._isKeyDown.clear()
this.removeAllListeners()
logger.info('HotkeyService disposed')
}
// ============================================================
// 내부: 키 이벤트 처리
// ============================================================
/**
* .
* .
*/
private _handleKeyDown(e: UiohookKeyboardEvent): void {
const matched = this._findMatchingHotkey(e)
if (!matched) return
const { id } = matched
// 키 반복(auto-repeat) 무시: 이미 눌려있으면 건너뜀
if (this._isKeyDown.get(id)) {
return
}
this._isKeyDown.set(id, true)
const now = Date.now()
// press 시작 시각 기록 (hold duration 계산용)
this._pressStartTime.set(id, now)
// 더블프레스 감지
if (matched.doublePressEnabled) {
const lastPress = this._lastPressTime.get(id)
if (lastPress !== undefined && now - lastPress < TIMING.DOUBLE_PRESS_DURATION) {
// 300ms 이내 연속 두 번 press = 더블프레스
this._lastPressTime.delete(id)
logger.debug(`Double-press detected: "${id}" (interval=${now - lastPress}ms)`)
this.emit('double-press', {
config: matched,
intervalMs: now - lastPress,
timestamp: now
})
return
}
this._lastPressTime.set(id, now)
}
logger.debug(`Hotkey pressed: "${id}"`)
this.emit('hotkey-pressed', {
config: matched,
timestamp: now
})
}
/**
* .
*/
private _handleKeyUp(e: UiohookKeyboardEvent): void {
const matched = this._findMatchingHotkey(e)
if (!matched) return
const { id } = matched
// 눌려있지 않은 키의 release는 무시
if (!this._isKeyDown.get(id)) {
return
}
this._isKeyDown.set(id, false)
const now = Date.now()
const pressStart = this._pressStartTime.get(id)
const durationMs = pressStart !== undefined ? now - pressStart : 0
this._pressStartTime.delete(id)
logger.debug(`Hotkey released: "${id}" (duration=${durationMs}ms)`)
this.emit('hotkey-released', {
config: matched,
durationMs,
timestamp: now
})
}
/**
* uiohook .
* .
*/
private _findMatchingHotkey(e: UiohookKeyboardEvent): HotkeyConfig | null {
for (const config of this._registeredHotkeys.values()) {
if (!config.enabled) continue
// 키코드 일치 확인
if (e.keycode !== config.keyCode) continue
// 수정자 키 일치 확인
// 핫키에 지정된 수정자가 모두 눌려 있어야 하고,
// 지정되지 않은 수정자는 눌려 있으면 안 된다.
//
// 단, 핫키의 주 키 자체가 수정자 키인 경우(예: Right Alt)에는
// 해당 수정자의 altKey 등이 true로 올 수 있으므로, 주 키가 수정자인 경우
// 해당 수정자 검사를 건너뛴다.
const isKeyModifier = this._isModifierKeyCode(config.keyCode)
const wantsCtrl = config.modifiers.includes('ctrl')
const wantsAlt = config.modifiers.includes('alt')
const wantsShift = config.modifiers.includes('shift')
const wantsMeta = config.modifiers.includes('meta')
const ctrlMatch = isKeyModifier && this._isCtrlKeyCode(config.keyCode)
? true
: e.ctrlKey === wantsCtrl
const altMatch = isKeyModifier && this._isAltKeyCode(config.keyCode)
? true
: e.altKey === wantsAlt
const shiftMatch = isKeyModifier && this._isShiftKeyCode(config.keyCode)
? true
: e.shiftKey === wantsShift
const metaMatch = isKeyModifier && this._isMetaKeyCode(config.keyCode)
? true
: e.metaKey === wantsMeta
if (ctrlMatch && altMatch && shiftMatch && metaMatch) {
return config
}
}
return null
}
/** 주어진 uiohook 키코드가 수정자 키(Ctrl/Alt/Shift/Meta)인지 확인 */
private _isModifierKeyCode(keyCode: number): boolean {
return (
this._isCtrlKeyCode(keyCode) ||
this._isAltKeyCode(keyCode) ||
this._isShiftKeyCode(keyCode) ||
this._isMetaKeyCode(keyCode)
)
}
private _isCtrlKeyCode(keyCode: number): boolean {
return keyCode === UiohookKey.Ctrl || keyCode === UiohookKey.CtrlRight
}
private _isAltKeyCode(keyCode: number): boolean {
return keyCode === UiohookKey.Alt || keyCode === UiohookKey.AltRight
}
private _isShiftKeyCode(keyCode: number): boolean {
return keyCode === UiohookKey.Shift || keyCode === UiohookKey.ShiftRight
}
private _isMetaKeyCode(keyCode: number): boolean {
return keyCode === UiohookKey.Meta || keyCode === UiohookKey.MetaRight
}
// ============================================================
// EventEmitter 타입 오버라이드
// ============================================================
override on<K extends keyof HotkeyServiceEvents>(
event: K,
listener: HotkeyServiceEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof HotkeyServiceEvents>(
event: K,
listener: HotkeyServiceEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof HotkeyServiceEvents>(
event: K,
...args: Parameters<HotkeyServiceEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// ============================================================
// 싱글톤
// ============================================================
let instance: HotkeyService | null = null
export function getHotkeyService(): HotkeyService {
if (!instance) {
instance = new HotkeyService()
}
return instance
}

View file

@ -0,0 +1,729 @@
// src/main/services/LocalSTTService.ts
// faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환한다.
// 싱글톤 + EventEmitter 패턴. Speakly VoiceRecognitionService의
// 상태 머신 및 이중 조건 플러시 패턴 적용.
import { EventEmitter } from 'events'
import { type ChildProcess, spawn } from 'child_process'
import path from 'path'
import { app } from 'electron'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import type { STTModel, STTStatus, STTEngineState } from '@shared/types'
// ── 내부 타입 정의 ────────────────────────────────────────
/** STT 엔진 상태 머신 */
const enum STTState {
Uninitialized = 'uninitialized',
Loading = 'loading',
Ready = 'ready',
Transcribing = 'transcribing',
Error = 'error',
}
/** 전사 결과 세그먼트 */
export interface TranscriptionSegment {
readonly text: string
readonly start: number
readonly end: number
readonly confidence: number
}
/** 전사 결과 */
export interface TranscriptionResult {
readonly text: string
readonly segments: TranscriptionSegment[]
readonly language: string
readonly duration: number
readonly processingTime: number
}
/** 전사 옵션 */
export interface TranscribeOptions {
language?: string
initialPrompt?: string
vadFilter?: boolean
}
/** sidecar /health 응답 */
interface HealthResponse {
status: string
model: string | null
gpu: boolean
}
/** sidecar /load 응답 */
interface LoadResponse {
status: string
model_id: string
load_time_ms: number
}
/** sidecar /transcribe 응답 */
interface TranscribeResponse {
text: string
segments: Array<{
text: string
start: number
end: number
avg_logprob: number
}>
language: string
duration: number
processing_time: number
}
/** 이벤트 페이로드 */
export interface LocalSTTEvents {
'transcription-delta': { text: string; isFinal: boolean }
'transcription-complete': { result: TranscriptionResult }
'model-loaded': { model: STTModel; loadTimeMs: number }
'error': { error: D3ROError }
}
// ── 상수 ──────────────────────────────────────────────────
const SIDECAR_PORT = 18765
const HEALTH_CHECK_INTERVAL_MS = 1000
const HEALTH_CHECK_TIMEOUT_MS = 30000
const MAX_RESTART_COUNT = 3
const SIDECAR_REQUEST_TIMEOUT_MS = 120000
/** 알려진 Whisper 모델 카탈로그 */
const MODEL_CATALOG: STTModel[] = [
{
id: 'tiny',
name: 'Tiny',
sizeBytes: 75_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 1,
speed: 5,
},
{
id: 'base',
name: 'Base',
sizeBytes: 141_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 2,
speed: 4,
},
{
id: 'small',
name: 'Small',
sizeBytes: 466_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 3,
speed: 3,
},
{
id: 'medium',
name: 'Medium',
sizeBytes: 1_500_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 4,
speed: 2,
},
{
id: 'large-v3',
name: 'Large V3',
sizeBytes: 3_100_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 5,
speed: 1,
},
]
// ── 서비스 구현 ───────────────────────────────────────────
const logger = getLogger('LocalSTTService')
class LocalSTTService extends EventEmitter {
private _state: STTState = STTState.Uninitialized
private _sidecarProcess: ChildProcess | null = null
private _port: number = SIDECAR_PORT
private _currentModelId: string | null = null
private _restartCount: number = 0
private _disposed: boolean = false
private _gpuAccelerated: boolean = false
// ── 이중 조건 플러시 (Speakly 패턴) ──
private _modelReady: boolean = false
private _audioBuffer: Buffer[] = []
private _pendingResolve: ((result: TranscriptionResult) => void) | null = null
private _pendingReject: ((error: D3ROError) => void) | null = null
// errorEmitted 플래그로 이벤트 중복 방지
private _errorEmitted: boolean = false
// ── 상태 접근자 ──
get state(): STTState {
return this._state
}
get currentModelId(): string | null {
return this._currentModelId
}
// ── 공개 메서드 ──
/**
* Whisper sidecar + .
* .
*/
async initialize(modelId?: string): Promise<void> {
const targetModel = modelId ?? configGet('sttModelId')
if (this._disposed) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
'LocalSTTService가 이미 dispose되었습니다',
)
}
// 이미 같은 모델이 로딩된 상태면 무시
if (
this._state === STTState.Ready &&
this._currentModelId === targetModel
) {
logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`)
return
}
this._setState(STTState.Loading)
this._errorEmitted = false
try {
// sidecar가 아직 실행 중이 아니면 시작
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
await this._spawnSidecar()
await this._waitForHealth()
}
// 모델 로딩
await this._loadModel(targetModel)
this._currentModelId = targetModel
this._modelReady = true
this._setState(STTState.Ready)
// 이중 조건 플러시 시도
this._tryFlushAll()
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
} catch (err) {
this._setState(STTState.Error)
const d3roErr =
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.STTModelLoadFailed,
`STT 초기화 실패: ${err instanceof Error ? err.message : String(err)}`,
)
this._emitError(d3roErr)
throw d3roErr
}
}
/**
* .
* PCM16 16kHz mono .
* 플러시: 모델 .
*/
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
): Promise<TranscriptionResult> {
if (this._disposed) {
throw new D3ROError(
ErrorCode.STTTranscriptionFailed,
'LocalSTTService가 이미 dispose되었습니다',
)
}
if (audioBuffer.length === 0) {
throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다')
}
// 모델이 아직 준비되지 않았으면 버퍼에 적재하고 대기
if (!this._modelReady) {
logger.debug('모델 로딩 중, 오디오 버퍼에 적재')
this._audioBuffer.push(audioBuffer)
return new Promise<TranscriptionResult>((resolve, reject) => {
this._pendingResolve = resolve
this._pendingReject = reject
// 이중 조건 플러시 시도 (모델이 이미 준비되었을 수 있음)
this._tryFlushAll()
})
}
// 모델 준비 완료 상태: 직접 전사
return this._sendToSidecar(audioBuffer, options)
}
/**
* .
* sidecar에 (faster-whisper가 ).
*/
getModels(): STTModel[] {
return MODEL_CATALOG.map((m) => ({
...m,
// 현재 로딩된 모델은 downloaded=true로 표시
downloaded: m.id === this._currentModelId ? true : m.downloaded,
}))
}
/** 현재 상태 조회 */
getStatus(): STTStatus {
const stateMap: Record<STTState, STTEngineState> = {
[STTState.Uninitialized]: 'not-installed' as STTEngineState,
[STTState.Loading]: 'loading' as STTEngineState,
[STTState.Ready]: 'ready' as STTEngineState,
[STTState.Transcribing]: 'processing' as STTEngineState,
[STTState.Error]: 'error' as STTEngineState,
}
return {
engineState: stateMap[this._state],
activeModel: this._currentModelId,
engineVersion: null,
gpuAccelerated: this._gpuAccelerated,
}
}
/** sidecar 프로세스 종료 및 리소스 정리 */
async dispose(): Promise<void> {
if (this._disposed) return
this._disposed = true
logger.info('LocalSTTService dispose 시작')
// pending promise를 reject
if (this._pendingReject) {
this._pendingReject(
new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 전사 취소'),
)
this._pendingResolve = null
this._pendingReject = null
}
this._audioBuffer = []
this._modelReady = false
await this._shutdownSidecar()
this._setState(STTState.Uninitialized)
logger.info('LocalSTTService dispose 완료')
}
// ── 이중 조건 플러시 (Speakly 핵심 패턴) ──
/**
* .
* () , .
*/
private _tryFlushAll(): void {
if (!this._modelReady) return
if (this._audioBuffer.length === 0) return
if (!this._pendingResolve) return
const merged = Buffer.concat(this._audioBuffer)
this._audioBuffer = []
const resolve = this._pendingResolve
const reject = this._pendingReject
this._pendingResolve = null
this._pendingReject = null
this._sendToSidecar(merged)
.then(resolve)
.catch((err: unknown) => {
if (reject) {
reject(
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패: ${err instanceof Error ? err.message : String(err)}`,
),
)
}
})
}
// ── Sidecar 관리 ──
private _getSidecarPath(): string {
const basePath = app.isPackaged ? process.resourcesPath : app.getAppPath()
return path.join(basePath, 'sidecar', 'main.py')
}
private async _spawnSidecar(): Promise<void> {
const sidecarPath = this._getSidecarPath()
logger.info(`Sidecar 시작: python ${sidecarPath} --port ${this._port}`)
return new Promise<void>((resolve, reject) => {
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
try {
this._sidecarProcess = spawn(
pythonCmd,
[sidecarPath, '--port', String(this._port)],
{
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
},
)
} catch (err) {
const d3roErr = new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`Sidecar 프로세스 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
)
reject(d3roErr)
return
}
const sidecarLogger = getLogger('sidecar')
this._sidecarProcess.stdout?.on('data', (data: Buffer) => {
const text = data.toString().trim()
if (text) {
sidecarLogger.info(text)
}
})
this._sidecarProcess.stderr?.on('data', (data: Buffer) => {
const text = data.toString().trim()
if (text) {
sidecarLogger.warn(text)
}
})
this._sidecarProcess.on('error', (err: Error) => {
logger.error(`Sidecar 프로세스 에러: ${err.message}`)
reject(
new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`Sidecar 프로세스 에러: ${err.message}`,
),
)
})
this._sidecarProcess.on('exit', (code: number | null, signal: string | null) => {
logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`)
this._sidecarProcess = null
this._modelReady = false
if (!this._disposed) {
this._handleSidecarCrash()
}
})
// spawn 자체는 비동기적이므로 즉시 resolve
// 실제 준비는 _waitForHealth에서 확인
resolve()
})
}
private async _waitForHealth(): Promise<void> {
const startTime = Date.now()
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
try {
const response = await fetch(`http://localhost:${this._port}/health`, {
signal: AbortSignal.timeout(2000),
})
if (response.ok) {
const data = (await response.json()) as HealthResponse
this._gpuAccelerated = data.gpu
logger.info(
`Sidecar 헬스체크 성공: status=${data.status}, gpu=${data.gpu}`,
)
return
}
} catch {
// 아직 준비 안 됨, 재시도
}
await this._sleep(HEALTH_CHECK_INTERVAL_MS)
}
throw new D3ROError(
ErrorCode.STTSidecarCommunicationFailed,
`Sidecar 헬스체크 타임아웃 (${HEALTH_CHECK_TIMEOUT_MS}ms)`,
)
}
private async _loadModel(modelId: string): Promise<void> {
logger.info(`모델 로딩 시작: ${modelId}`)
const startTime = Date.now()
const response = await fetch(`http://localhost:${this._port}/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId }),
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
})
if (!response.ok) {
const errorText = await response.text()
throw new D3ROError(
ErrorCode.STTModelLoadFailed,
`모델 로딩 실패 (HTTP ${response.status}): ${errorText}`,
)
}
const data = (await response.json()) as LoadResponse
const loadTimeMs = Date.now() - startTime
const model = MODEL_CATALOG.find((m) => m.id === modelId)
if (model) {
this.emit('model-loaded', {
model: { ...model, downloaded: true },
loadTimeMs,
})
}
logger.info(
`모델 로딩 완료: ${data.model_id}, ${loadTimeMs}ms`,
)
}
// ── HTTP 전사 요청 ──
private async _sendToSidecar(
audioBuffer: Buffer,
options?: TranscribeOptions,
): Promise<TranscriptionResult> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
throw new D3ROError(
ErrorCode.STTSidecarCommunicationFailed,
'Sidecar 프로세스가 실행 중이 아닙니다',
)
}
this._setState(STTState.Transcribing)
const startTime = Date.now()
try {
const language = options?.language ?? configGet('sttLanguage')
const vadFilter = options?.vadFilter ?? true
const initialPrompt = options?.initialPrompt ?? ''
// Node 18+ 내장 fetch + FormData + Blob으로 multipart 전송
const formData = new FormData()
// Buffer → ArrayBuffer 복사 후 Blob 생성 (Node/Electron 타입 호환)
const arrayBuf = audioBuffer.buffer.slice(
audioBuffer.byteOffset,
audioBuffer.byteOffset + audioBuffer.byteLength,
) as ArrayBuffer
formData.append(
'audio',
new Blob([arrayBuf], { type: 'application/octet-stream' }),
'audio.pcm',
)
formData.append('language', language)
formData.append('vad_filter', String(vadFilter))
if (initialPrompt) {
formData.append('initial_prompt', initialPrompt)
}
const response = await fetch(
`http://localhost:${this._port}/transcribe`,
{
method: 'POST',
body: formData,
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
},
)
if (!response.ok) {
const errorText = await response.text()
throw new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패 (HTTP ${response.status}): ${errorText}`,
)
}
const data = (await response.json()) as TranscribeResponse
const processingTime = Date.now() - startTime
const result: TranscriptionResult = {
text: data.text,
segments: data.segments.map((seg) => ({
text: seg.text,
start: seg.start,
end: seg.end,
confidence: Math.exp(seg.avg_logprob),
})),
language: data.language,
duration: data.duration,
processingTime,
}
// 중간 결과 이벤트 (isFinal=true)
this.emit('transcription-delta', { text: result.text, isFinal: true })
this.emit('transcription-complete', { result })
this._setState(STTState.Ready)
logger.info(
`전사 완료: "${result.text.substring(0, 50)}..." (${processingTime}ms, lang=${result.language})`,
)
return result
} catch (err) {
this._setState(STTState.Ready) // 에러 후에도 Ready 복귀 (sidecar가 살아있으면)
if (err instanceof D3ROError) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
// 타임아웃 구분
if (message.includes('abort') || message.includes('timeout')) {
throw new D3ROError(
ErrorCode.STTTranscriptionTimeout,
`전사 타임아웃: ${message}`,
)
}
throw new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패: ${message}`,
)
}
}
// ── Sidecar Crash 처리 ──
private _handleSidecarCrash(): void {
if (this._disposed) return
this._restartCount++
logger.warn(`Sidecar crash 감지, 재시작 시도 ${this._restartCount}/${MAX_RESTART_COUNT}`)
if (this._restartCount > MAX_RESTART_COUNT) {
const err = new D3ROError(
ErrorCode.STTSidecarCrashed,
`Sidecar가 ${MAX_RESTART_COUNT}회 crash 후 재시작 포기`,
)
this._setState(STTState.Error)
this._emitError(err)
// pending promise reject
if (this._pendingReject) {
this._pendingReject(err)
this._pendingResolve = null
this._pendingReject = null
}
return
}
// 비동기 재시작
const modelToReload = this._currentModelId
this._currentModelId = null
this._modelReady = false
// setTimeout으로 이벤트 루프에 양보
setTimeout(() => {
if (this._disposed) return
this.initialize(modelToReload ?? undefined).catch((err: unknown) => {
logger.error(
`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`,
)
})
}, 1000 * this._restartCount) // 점진적 백오프
}
private async _shutdownSidecar(): Promise<void> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
this._sidecarProcess = null
return
}
logger.info('Sidecar 종료 요청')
try {
// POST /shutdown 요청
await fetch(`http://localhost:${this._port}/shutdown`, {
method: 'POST',
signal: AbortSignal.timeout(3000),
})
} catch {
// 이미 종료되었거나 통신 불가 — 무시
}
// 프로세스가 아직 살아있으면 강제 종료
if (this._sidecarProcess && this._sidecarProcess.exitCode === null) {
logger.warn('Sidecar graceful shutdown 실패, SIGKILL 전송')
this._sidecarProcess.kill('SIGKILL')
}
this._sidecarProcess = null
}
// ── 내부 유틸 ──
private _setState(newState: STTState): void {
if (this._state === newState) return
const prev = this._state
this._state = newState
logger.debug(`STTState: ${prev} -> ${newState}`)
}
private _emitError(error: D3ROError): void {
if (this._errorEmitted) return
this._errorEmitted = true
this.emit('error', { error })
logger.error(`STT 에러: [${error.code}] ${error.message}`)
}
private _sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ── 타입 안전한 이벤트 메서드 오버라이드 ──
override emit<K extends keyof LocalSTTEvents>(
event: K,
payload: LocalSTTEvents[K],
): boolean {
return super.emit(event, payload)
}
override on<K extends keyof LocalSTTEvents>(
event: K,
listener: (payload: LocalSTTEvents[K]) => void,
): this {
return super.on(event, listener)
}
override off<K extends keyof LocalSTTEvents>(
event: K,
listener: (payload: LocalSTTEvents[K]) => void,
): this {
return super.off(event, listener)
}
}
// ── 싱글톤 ──
let instance: LocalSTTService | null = null
export function getLocalSTTService(): LocalSTTService {
if (!instance) {
instance = new LocalSTTService()
}
return instance
}
export { LocalSTTService, STTState }

View file

@ -0,0 +1,32 @@
// src/main/services/LoggerService.ts
// electron-log 래퍼. 카테고리별 로깅을 지원한다.
import log from 'electron-log'
export interface CategoryLogger {
info(message: string, ...args: unknown[]): void
warn(message: string, ...args: unknown[]): void
error(message: string, ...args: unknown[]): void
debug(message: string, ...args: unknown[]): void
}
let initialized = false
export function initLoggerService(): void {
if (initialized) return
initialized = true
log.transports.file.maxSize = 5 * 1024 * 1024 // 5MB
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'
log.transports.console.format = '[{h}:{i}:{s}.{ms}] [{level}] {text}'
}
export function getLogger(category: string): CategoryLogger {
const prefix = `[${category}]`
return {
info: (message: string, ...args: unknown[]) => log.info(`${prefix} ${message}`, ...args),
warn: (message: string, ...args: unknown[]) => log.warn(`${prefix} ${message}`, ...args),
error: (message: string, ...args: unknown[]) => log.error(`${prefix} ${message}`, ...args),
debug: (message: string, ...args: unknown[]) => log.debug(`${prefix} ${message}`, ...args)
}
}

View file

@ -0,0 +1,625 @@
// src/main/services/VoiceModeService.ts
// 전체 음성 파이프라인 오케스트레이터.
// 설계서 01의 IVoiceModeService 구현. Speakly VoiceModeService의
// 상태 머신, 이중 조건 플러시, Action Queue 패턴 적용.
import { EventEmitter } from 'events'
import { randomUUID } from 'crypto'
import { getLogger } from './LoggerService'
import { getAudioCaptureService } from './AudioCaptureService'
import { getLocalSTTService } from './LocalSTTService'
import type { TranscriptionResult } from './LocalSTTService'
import { getHotkeyService } from './HotkeyService'
import type { HotkeyConfig } from './HotkeyService'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import { TIMING } from '@shared/constants'
import { RecognitionState, AudioState } from '@shared/types'
import type { VoiceMode, VoiceState } from '@shared/types'
const logger = getLogger('VoiceModeService')
// ============================================================
// 내부 타입
// ============================================================
interface VoiceSession {
id: string
mode: VoiceMode
startedAt: number
recognitionState: RecognitionState
audioState: AudioState
audioBufferDurationMs: number
transcription: string
processedText: string | null
accidentalPress: boolean
}
interface VoiceAction {
type: 'press' | 'release' | 'escape'
timestamp: number
mode: VoiceMode
hotkeyId: string
}
interface VoiceModeEvents {
'session-started': (payload: { session: VoiceSession }) => void
'recognition-state-changed': (payload: {
previous: RecognitionState
current: RecognitionState
}) => void
'audio-state-changed': (payload: {
previous: AudioState
current: AudioState
}) => void
'transcription-update': (payload: { text: string; isFinal: boolean }) => void
'session-completed': (payload: { session: VoiceSession; finalText: string }) => void
'session-cancelled': (payload: {
session: VoiceSession
reason: 'user' | 'timeout' | 'too-short'
}) => void
'audio-level': (payload: { level: number }) => void
error: (payload: { error: D3ROError; session: VoiceSession | null }) => void
}
// ============================================================
// 터미널 상태 헬퍼
// ============================================================
const TERMINAL_STATES = new Set<RecognitionState>([
RecognitionState.COMPLETED,
RecognitionState.CANCELLED,
RecognitionState.ERROR,
RecognitionState.DESTROYED
])
// ============================================================
// VoiceModeService
// ============================================================
class VoiceModeService extends EventEmitter {
private _session: VoiceSession | null = null
private _recognitionState = RecognitionState.IDLE
private _audioState = AudioState.IDLE
// 이중 조건 플러시
private _sttReady = false
private _audioStarted = false
private _audioBuffer: Buffer[] = []
private _audioBufferBytes = 0
// 에러 가드
private _errorEmitted = false
// Action Queue (이벤트 직렬화)
private _actionQueue: VoiceAction[] = []
private _isProcessingQueue = false
// 리스너 해제용 참조
private _audioDataHandler: ((payload: { buffer: Buffer }) => void) | null = null
private _audioLevelHandler: ((payload: { level: number }) => void) | null = null
private _hotkeyPressHandler: ((payload: { config: HotkeyConfig; timestamp: number }) => void) | null = null
private _hotkeyReleaseHandler: ((payload: { config: HotkeyConfig; durationMs: number; timestamp: number }) => void) | null = null
private _doublePressHandler: ((payload: { config: HotkeyConfig }) => void) | null = null
private _disposed = false
get currentSession(): VoiceSession | null {
return this._session
}
get isActive(): boolean {
return this._session !== null && !this._isInTerminalState()
}
getState(): VoiceState {
return {
recognitionState: this._recognitionState,
audioState: this._audioState,
mode: configGet('defaultLLMAction') === 'translate' ? 'hands-free' : 'dictation',
sessionId: this._session?.id ?? null,
recordingStartedAt: this._session?.startedAt ?? null
}
}
// ── 초기화 ──────────────────────────────────────────────
/**
* HotkeyService .
* bootstrap에서 .
*/
connectHotkey(): void {
const hotkey = getHotkeyService()
this._hotkeyPressHandler = (payload) => {
const mode = this._resolveMode(payload.config)
this._enqueueAction({ type: 'press', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
}
this._hotkeyReleaseHandler = (payload) => {
const mode = this._resolveMode(payload.config)
this._enqueueAction({ type: 'release', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
}
this._doublePressHandler = (payload) => {
// 더블프레스 → hands-free 모드 토글
this._enqueueAction({ type: 'press', timestamp: Date.now(), mode: 'hands-free', hotkeyId: payload.config.id })
}
hotkey.on('hotkey-pressed', this._hotkeyPressHandler)
hotkey.on('hotkey-released', this._hotkeyReleaseHandler)
hotkey.on('double-press', this._doublePressHandler)
logger.info('Hotkey events connected')
}
// ── 세션 제어 ──────────────────────────────────────────
async startSession(mode: VoiceMode): Promise<void> {
if (this._disposed) return
if (this._session && !this._isInTerminalState()) {
logger.warn('Session already active, ignoring startSession')
return
}
// 세션 생성
this._session = {
id: randomUUID(),
mode,
startedAt: Date.now(),
recognitionState: RecognitionState.PREPARING,
audioState: AudioState.IDLE,
audioBufferDurationMs: 0,
transcription: '',
processedText: null,
accidentalPress: false
}
this._errorEmitted = false
this._sttReady = false
this._audioStarted = false
this._audioBuffer = []
this._audioBufferBytes = 0
this._setRecognitionState(RecognitionState.PREPARING)
this.emit('session-started', { session: this._session })
logger.info(`Session started: ${this._session.id} (mode: ${mode})`)
// 이중 조건 플러시: STT 초기화 + 오디오 캡처를 병렬 시작
const sttPromise = this._initSTT()
const audioPromise = this._startAudio()
// 둘 다 에러여도 개별 처리하므로 allSettled
await Promise.allSettled([sttPromise, audioPromise])
}
async stopSession(): Promise<void> {
if (!this._session || this._isInTerminalState()) return
const session = this._session
const duration = Date.now() - session.startedAt
// accidentalPress 체크 (700ms 미만)
if (duration < TIMING.MIN_AUDIO_DURATION) {
session.accidentalPress = true
logger.info(`Accidental press detected (${duration}ms < ${TIMING.MIN_AUDIO_DURATION}ms)`)
this._cancelSession('too-short')
return
}
// 오디오 캡처 중지
await this._stopAudio()
// 버퍼가 있으면 STT에 전달
if (this._audioBuffer.length > 0 && this._sttReady) {
await this._transcribe()
} else if (this._audioBuffer.length > 0 && !this._sttReady) {
// STT 아직 준비 안 됨 → tryFlushAll이 처리
logger.info('Waiting for STT to be ready before transcribing')
// 타임아웃 설정
setTimeout(() => {
if (this._session?.id === session.id && !this._isInTerminalState()) {
logger.warn('STT readiness timeout, cancelling session')
this._cancelSession('timeout')
}
}, TIMING.POST_RECORDING_WAIT_BUFFERED)
} else {
// 오디오 없음
logger.warn('No audio buffer, cancelling session')
this._cancelSession('too-short')
}
}
cancelSession(): void {
this._cancelSession('user')
}
// ── 상태 머신 ──────────────────────────────────────────
private _isInTerminalState(): boolean {
return TERMINAL_STATES.has(this._recognitionState)
}
private _setRecognitionState(state: RecognitionState): void {
if (this._recognitionState === state) return
if (this._isInTerminalState() && state !== RecognitionState.IDLE) return
const previous = this._recognitionState
this._recognitionState = state
if (this._session) {
this._session.recognitionState = state
}
this.emit('recognition-state-changed', { previous, current: state })
logger.debug(`RecognitionState: ${previous}${state}`)
}
private _setAudioState(state: AudioState): void {
if (this._audioState === state) return
const previous = this._audioState
this._audioState = state
if (this._session) {
this._session.audioState = state
}
this.emit('audio-state-changed', { previous, current: state })
logger.debug(`AudioState: ${previous}${state}`)
}
// ── STT 초기화 ─────────────────────────────────────────
private async _initSTT(): Promise<void> {
try {
this._setRecognitionState(RecognitionState.CONNECTING)
const stt = getLocalSTTService()
const modelId = configGet('sttModelId')
await stt.initialize(modelId)
if (this._isInTerminalState()) return
this._sttReady = true
this._setRecognitionState(RecognitionState.READY)
logger.info('STT ready')
this._tryFlushAll()
} catch (error) {
if (this._isInTerminalState()) return
this._handleError(
new D3ROError(
ErrorCode.STTModelLoadFailed,
`STT initialization failed: ${error instanceof Error ? error.message : String(error)}`
)
)
}
}
// ── 오디오 캡처 ────────────────────────────────────────
private async _startAudio(): Promise<void> {
try {
this._setAudioState(AudioState.INITIALIZING)
const audio = getAudioCaptureService()
// 오디오 데이터 수신
this._audioDataHandler = (payload) => {
if (this._isInTerminalState()) return
this._audioBuffer.push(payload.buffer)
this._audioBufferBytes += payload.buffer.length
// 버퍼 duration 업데이트 (16kHz, 16bit, mono)
const durationMs = (this._audioBufferBytes / 2 / 16000) * 1000
if (this._session) {
this._session.audioBufferDurationMs = durationMs
}
this._tryFlushAll()
}
this._audioLevelHandler = (payload) => {
this.emit('audio-level', { level: payload.level })
}
audio.on('audio-data', this._audioDataHandler)
audio.on('audio-level', this._audioLevelHandler)
await audio.start()
if (this._isInTerminalState()) return
this._audioStarted = true
this._setAudioState(AudioState.STREAMING)
logger.info('Audio capture started')
this._tryFlushAll()
} catch (error) {
if (this._isInTerminalState()) return
this._setAudioState(AudioState.STOPPED)
this._handleError(
new D3ROError(
ErrorCode.AudioCaptureStartFailed,
`Audio capture failed: ${error instanceof Error ? error.message : String(error)}`
)
)
}
}
private async _stopAudio(): Promise<void> {
this._setAudioState(AudioState.STOPPED)
const audio = getAudioCaptureService()
if (this._audioDataHandler) {
audio.off('audio-data', this._audioDataHandler)
this._audioDataHandler = null
}
if (this._audioLevelHandler) {
audio.off('audio-level', this._audioLevelHandler)
this._audioLevelHandler = null
}
try {
await audio.stop()
} catch (error) {
logger.warn(`Audio stop error: ${error instanceof Error ? error.message : String(error)}`)
}
}
// ── 이중 조건 플러시 ───────────────────────────────────
private _tryFlushAll(): void {
if (!this._sttReady || !this._audioStarted) return
if (this._audioBuffer.length === 0) return
if (this._isInTerminalState()) return
// 아직 녹음 중이면 flush 하지 않음 (stopSession에서 처리)
if (this._audioState === AudioState.STREAMING) return
this._transcribe()
}
// ── 전사 ───────────────────────────────────────────────
private async _transcribe(): Promise<void> {
if (this._audioBuffer.length === 0) return
if (this._isInTerminalState()) return
this._setRecognitionState(RecognitionState.RECOGNIZING)
const merged = Buffer.concat(this._audioBuffer)
this._audioBuffer = []
this._audioBufferBytes = 0
try {
const stt = getLocalSTTService()
const language = configGet('sttLanguage')
const result: TranscriptionResult = await stt.transcribe(merged, {
language: language === 'auto' ? undefined : language
})
if (this._isInTerminalState()) return
if (this._session) {
this._session.transcription = result.text
}
this.emit('transcription-update', { text: result.text, isFinal: true })
// Phase 2: LLM 후처리 없이 바로 완료
this._completeSession(result.text)
} catch (error) {
if (this._isInTerminalState()) return
this._handleError(
new D3ROError(
ErrorCode.STTTranscriptionFailed,
`Transcription failed: ${error instanceof Error ? error.message : String(error)}`
)
)
}
}
// ── 세션 완료/취소 ─────────────────────────────────────
private _completeSession(finalText: string): void {
if (!this._session) return
this._setRecognitionState(RecognitionState.COMPLETED)
this._setAudioState(AudioState.STOPPED)
const session = { ...this._session }
logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`)
this.emit('session-completed', { session, finalText })
// IDLE로 복귀
this._resetToIdle()
}
private _cancelSession(reason: 'user' | 'timeout' | 'too-short'): void {
if (!this._session) return
this._setRecognitionState(RecognitionState.CANCELLED)
this._setAudioState(AudioState.STOPPED)
const session = { ...this._session }
logger.info(`Session cancelled: ${reason}`)
// 오디오 정리
this._stopAudio()
this.emit('session-cancelled', { session, reason })
this._resetToIdle()
}
private _resetToIdle(): void {
this._session = null
this._audioBuffer = []
this._audioBufferBytes = 0
this._sttReady = false
this._audioStarted = false
this._errorEmitted = false
// 약간의 딜레이 후 IDLE로 전이 (UI 애니메이션용)
setTimeout(() => {
if (!this._session) {
this._setRecognitionState(RecognitionState.IDLE)
this._setAudioState(AudioState.IDLE)
}
}, 200)
}
// ── 에러 처리 ──────────────────────────────────────────
private _handleError(error: D3ROError): void {
if (this._errorEmitted) return
this._errorEmitted = true
logger.error(`VoiceMode error [${error.code}]: ${error.message}`)
this._setRecognitionState(RecognitionState.ERROR)
this._stopAudio()
this.emit('error', { error, session: this._session ? { ...this._session } : null })
this._resetToIdle()
}
// ── Action Queue (이벤트 직렬화) ───────────────────────
private _enqueueAction(action: VoiceAction): void {
this._actionQueue.push(action)
this._processQueue()
}
private async _processQueue(): Promise<void> {
if (this._isProcessingQueue) return
this._isProcessingQueue = true
try {
while (this._actionQueue.length > 0) {
const action = this._actionQueue.shift()!
await this._processAction(action)
}
} finally {
this._isProcessingQueue = false
}
}
private async _processAction(action: VoiceAction): Promise<void> {
try {
switch (action.type) {
case 'press':
await this._handlePress(action)
break
case 'release':
await this._handleRelease(action)
break
case 'escape':
this.cancelSession()
break
}
} catch (error) {
logger.error(`Action processing error: ${error instanceof Error ? error.message : String(error)}`)
}
}
private async _handlePress(action: VoiceAction): Promise<void> {
if (action.mode === 'dictation') {
// Dictation: hold-to-talk — press로 시작
if (!this.isActive) {
await this.startSession('dictation')
}
} else if (action.mode === 'hands-free') {
// HandsFree: toggle
if (this.isActive) {
await this.stopSession()
} else {
await this.startSession('hands-free')
}
}
}
private async _handleRelease(action: VoiceAction): Promise<void> {
if (action.mode === 'dictation' && this.isActive) {
// Dictation: hold-to-talk — release로 종료 (200ms 딜레이)
await new Promise<void>((resolve) => setTimeout(resolve, 200))
await this.stopSession()
}
// HandsFree: release 무시
}
// ── 유틸리티 ───────────────────────────────────────────
private _resolveMode(config: HotkeyConfig): VoiceMode {
if (config.id === 'voice-handsfree' || config.doublePressEnabled) {
return 'hands-free'
}
return 'dictation'
}
// ── 종료 ───────────────────────────────────────────────
dispose(): void {
this._disposed = true
this._actionQueue = []
// 진행 중 세션 취소
if (this._session && !this._isInTerminalState()) {
this._cancelSession('user')
}
// 핫키 리스너 해제
const hotkey = getHotkeyService()
if (this._hotkeyPressHandler) {
hotkey.off('hotkey-pressed', this._hotkeyPressHandler)
}
if (this._hotkeyReleaseHandler) {
hotkey.off('hotkey-released', this._hotkeyReleaseHandler)
}
if (this._doublePressHandler) {
hotkey.off('double-press', this._doublePressHandler)
}
// 오디오 리스너 해제
this._stopAudio()
this._setRecognitionState(RecognitionState.DESTROYED)
this.removeAllListeners()
logger.info('VoiceModeService disposed')
}
// ── EventEmitter 타입 오버라이드 ───────────────────────
override on<K extends keyof VoiceModeEvents>(
event: K,
listener: VoiceModeEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof VoiceModeEvents>(
event: K,
listener: VoiceModeEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof VoiceModeEvents>(
event: K,
...args: Parameters<VoiceModeEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// ── 싱글톤 ─────────────────────────────────────────────
let instance: VoiceModeService | null = null
export function getVoiceModeService(): VoiceModeService {
if (!instance) {
instance = new VoiceModeService()
}
return instance
}

View file

@ -0,0 +1,26 @@
// src/main/services/index.ts — 서비스 레지스트리
export { initLoggerService, getLogger } from './LoggerService'
export {
initConfigService,
getConfigService,
configGet,
configSet,
configGetAll,
configReset,
onConfigChanged
} from './ConfigService'
export {
getLocalSTTService,
LocalSTTService,
STTState
} from './LocalSTTService'
export type {
TranscriptionResult,
TranscriptionSegment,
TranscribeOptions,
LocalSTTEvents
} from './LocalSTTService'
export { getHotkeyService } from './HotkeyService'
export { getVoiceModeService } from './VoiceModeService'
export { getAudioCaptureService } from './AudioCaptureService'