Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템
Phase 10 킬러 피처: - MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB) - VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종 - ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트 - ChainService: LLM 명령어 순차 실행 파이프라인 - CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백 VoiceModeService 파이프라인 통합: - 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입 시스템 오디오 캡처: - setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지) - electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현 Phase 11 수익화: - LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API - Feature Gate: requireFeature/checkFeature/consumeFeature - 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage) - LicenseModal, ProBadge, UpgradePromptModal UI 디자인 보강: - d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템 - ScreenPanel, ButtonGroup DS 컴포넌트 신규 - PhosphorText 4→13종 변형, MetalDial conic-gradient 광택 - 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard 기타: - 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings) - StatusBar 자막 LED + 효과음, 자막 로딩 UI - LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged) - 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
parent
36d77ca224
commit
a31f96bbb8
97 changed files with 11853 additions and 1143 deletions
335
src/main/services/VoiceCommandService.ts
Normal file
335
src/main/services/VoiceCommandService.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
// src/main/services/VoiceCommandService.ts
|
||||
// Phase 10.5: 음성 단축키 — 전사 텍스트에서 키워드를 감지하여 명령어 자동 선택.
|
||||
// electron-store에 VoiceCommandRule[] 저장, 키워드 매칭 엔진 제공.
|
||||
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import type {
|
||||
VoiceCommandRule,
|
||||
VoiceCommandKeyword,
|
||||
VoiceCommandMatch,
|
||||
KeywordMatchMode
|
||||
} from '@shared/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와 별도 네임스페이스)
|
||||
// ============================================================
|
||||
|
||||
// configGet/configSet에 타입이 없는 키를 사용하므로 as never 캐스팅 필요
|
||||
const STORE_KEY_RULES = 'voiceCommandRules' as never
|
||||
const STORE_KEY_ENABLED = 'voiceCommandsEnabled' as never
|
||||
|
||||
// ============================================================
|
||||
// 키워드 매칭 엔진
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 텍스트에서 키워드를 매칭하고, 매칭된 키워드를 제거한 정리된 텍스트를 반환한다.
|
||||
* 키워드 앞뒤의 공백/구두점 경계를 존중한다.
|
||||
*/
|
||||
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: nanoid(),
|
||||
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: nanoid(),
|
||||
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 as never)
|
||||
} 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 as never)
|
||||
} 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue