d3ro-voice/apps/desktop/src/main/services/VoiceCommandService.ts
Yun Chan 708e20f747
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
2026-08-20 11:12:05 +09:00

337 lines
9.8 KiB
TypeScript

// 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<DefaultKeywordEntry> = [
{
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
}