// src/main/services/VoiceCommandService.ts // Phase 10.5: 음성 단축키 — 전사 텍스트에서 키워드를 감지하여 명령어 자동 선택. // electron-store에 VoiceCommandRule[] 저장, 키워드 매칭 엔진 제공. import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' import type { VoiceCommandRule, VoiceCommandKeyword, VoiceCommandMatch, KeywordMatchMode } from '@d3ro/core/types' const logger = getLogger('voice-command') // ============================================================ // 기본 키워드 (프리셋 명령어용) // ============================================================ interface DefaultKeywordEntry { instructionId: string keywords: VoiceCommandKeyword[] priority: number } const DEFAULT_KEYWORDS: ReadonlyArray = [ { instructionId: 'builtin-translate', keywords: [ { keyword: '번역해줘', matchMode: 'prefix' }, { keyword: '번역', matchMode: 'prefix' }, { keyword: '영어로', matchMode: 'prefix' }, { keyword: 'translate', matchMode: 'prefix' } ], priority: 0 }, { instructionId: 'builtin-summarize', keywords: [ { keyword: '요약해줘', matchMode: 'prefix' }, { keyword: '요약', matchMode: 'prefix' }, { keyword: 'summarize', matchMode: 'prefix' } ], priority: 1 }, { instructionId: 'builtin-formal', keywords: [ { keyword: '다듬어줘', matchMode: 'prefix' }, { keyword: '다듬기', matchMode: 'prefix' }, { keyword: 'polish', matchMode: 'prefix' } ], priority: 2 }, { instructionId: 'builtin-explain-code', keywords: [ { keyword: '설명해줘', matchMode: 'prefix' }, { keyword: '설명', matchMode: 'prefix' }, { keyword: 'explain', matchMode: 'prefix' } ], priority: 3 } ] // ============================================================ // electron-store 키 (ConfigService와 별도 네임스페이스) // ============================================================ const STORE_KEY_RULES: keyof import('@d3ro/core/types').AppConfig = 'voiceCommandRules' const STORE_KEY_ENABLED: keyof import('@d3ro/core/types').AppConfig = 'voiceCommandsEnabled' // ============================================================ // 키워드 매칭 엔진 // ============================================================ /** * 텍스트에서 키워드를 매칭하고, 매칭된 키워드를 제거한 정리된 텍스트를 반환한다. * 키워드 앞뒤의 공백/구두점 경계를 존중한다. */ function matchKeyword( text: string, keyword: string, mode: KeywordMatchMode ): { matched: boolean; cleanedText: string } { const trimmed = text.trim() const lowerText = trimmed.toLowerCase() const lowerKeyword = keyword.toLowerCase() if (lowerKeyword.length === 0) { return { matched: false, cleanedText: trimmed } } switch (mode) { case 'prefix': { if (!lowerText.startsWith(lowerKeyword)) { return { matched: false, cleanedText: trimmed } } // 키워드 뒤가 끝이거나 공백/구두점이어야 정확한 prefix 매칭 const afterKeyword = trimmed.charAt(keyword.length) if (afterKeyword !== '' && !isWordBoundary(afterKeyword)) { return { matched: false, cleanedText: trimmed } } const cleaned = trimmed.slice(keyword.length).trimStart() return { matched: true, cleanedText: cleaned } } case 'suffix': { if (!lowerText.endsWith(lowerKeyword)) { return { matched: false, cleanedText: trimmed } } // 키워드 앞이 시작이거나 공백/구두점이어야 정확한 suffix 매칭 const beforeKeyword = trimmed.charAt(trimmed.length - keyword.length - 1) if (beforeKeyword !== '' && !isWordBoundary(beforeKeyword)) { return { matched: false, cleanedText: trimmed } } const cleaned = trimmed.slice(0, trimmed.length - keyword.length).trimEnd() return { matched: true, cleanedText: cleaned } } case 'contains': { const index = lowerText.indexOf(lowerKeyword) if (index === -1) { return { matched: false, cleanedText: trimmed } } // contains 모드에서는 경계 검사 없이 첫 번째 매칭만 제거 const before = trimmed.slice(0, index) const after = trimmed.slice(index + keyword.length) const cleaned = (before + after).replace(/\s{2,}/g, ' ').trim() return { matched: true, cleanedText: cleaned } } } } function isWordBoundary(char: string): boolean { // 공백, 구두점, 한국어 조사/어미 앞의 경계 return /[\s,.!?;:'"()[\]{}\-/]/.test(char) } // ============================================================ // VoiceCommandService // ============================================================ class VoiceCommandService { private rules: VoiceCommandRule[] = [] private enabled = false private initialized = false initialize(): void { if (this.initialized) return this.loadRules() this.loadEnabled() this.initialized = true logger.info( `VoiceCommandService initialized (${this.rules.length} rules, enabled=${this.enabled})` ) } /** * 전사 텍스트에서 키워드를 매칭하여 명령어를 자동 선택한다. * priority가 낮을수록 높은 우선순위 (0이 가장 높음). */ match(text: string): VoiceCommandMatch { const noMatch: VoiceCommandMatch = { matched: false, ruleId: null, instructionId: null, cleanedText: text, matchedKeyword: null } if (!this.enabled) { return noMatch } const trimmed = text.trim() if (trimmed.length === 0) { return noMatch } // priority 오름차순 정렬 (낮은 값 = 높은 우선순위) const sortedRules = [...this.rules] .filter((r) => r.enabled && r.keywords.length > 0) .sort((a, b) => a.priority - b.priority) for (const rule of sortedRules) { for (const kw of rule.keywords) { const result = matchKeyword(trimmed, kw.keyword, kw.matchMode) if (result.matched) { logger.info( `Voice command matched: rule="${rule.id}", keyword="${kw.keyword}", instruction="${rule.instructionId}"` ) return { matched: true, ruleId: rule.id, instructionId: rule.instructionId, cleanedText: result.cleanedText, matchedKeyword: kw.keyword } } } } return noMatch } getAllRules(): VoiceCommandRule[] { return [...this.rules].sort((a, b) => a.priority - b.priority) } setKeywordsForInstruction(instructionId: string, keywords: VoiceCommandKeyword[]): void { const existingIndex = this.rules.findIndex((r) => r.instructionId === instructionId) if (existingIndex !== -1) { // 기존 rule 업데이트 this.rules[existingIndex] = { ...this.rules[existingIndex], keywords } } else { // 새 rule 생성 const maxPriority = this.rules.reduce((max, r) => Math.max(max, r.priority), -1) this.rules.push({ id: crypto.randomUUID(), instructionId, keywords, enabled: true, priority: maxPriority + 1 }) } this.saveRules() logger.info( `Keywords updated for instruction "${instructionId}": ${keywords.length} keywords` ) } setEnabled(enabled: boolean): void { this.enabled = enabled this.saveEnabled() logger.info(`Voice commands ${enabled ? 'enabled' : 'disabled'}`) } isEnabled(): boolean { return this.enabled } /** * 최초 실행 시 기본 키워드를 생성한다. * 이미 rules가 존재하면 아무 것도 하지 않는다. */ initDefaultKeywords(): void { if (this.rules.length > 0) { logger.debug('Default keywords already initialized, skipping') return } for (const entry of DEFAULT_KEYWORDS) { this.rules.push({ id: crypto.randomUUID(), instructionId: entry.instructionId, keywords: [...entry.keywords], enabled: true, priority: entry.priority }) } this.saveRules() logger.info(`Default voice command keywords initialized (${this.rules.length} rules)`) } dispose(): void { logger.info('VoiceCommandService disposed') } // ── Private ────────────────────────────────────────── private loadRules(): void { try { const stored = configGet(STORE_KEY_RULES) as VoiceCommandRule[] | undefined if (Array.isArray(stored) && stored.length > 0) { this.rules = stored return } } catch { // 첫 실행 시 키가 없을 수 있음 } this.rules = [] } private saveRules(): void { try { configSet(STORE_KEY_RULES, this.rules) } catch (error) { logger.warn( `Failed to save voice command rules: ${error instanceof Error ? error.message : String(error)}` ) } } private loadEnabled(): void { try { const stored = configGet(STORE_KEY_ENABLED) as boolean | undefined this.enabled = stored === true } catch { this.enabled = false } } private saveEnabled(): void { try { configSet(STORE_KEY_ENABLED, this.enabled) } catch (error) { logger.warn( `Failed to save voice command enabled state: ${error instanceof Error ? error.message : String(error)}` ) } } } // ============================================================ // 싱글턴 // ============================================================ let instance: VoiceCommandService | null = null export function getVoiceCommandService(): VoiceCommandService { if (!instance) { instance = new VoiceCommandService() } return instance } export function resetVoiceCommandServiceForTests(): void { instance = null }