d3ro-voice/apps/desktop/src/main/bootstrap.ts
Yun Chan 2d585bfc29 feat(desktop): make local speech transcription work end to end
Local dictation had never produced a transcript on an installed build. The
engine itself was healthy; every connection to it was broken.

Installed builds shipped no speech engine at all: the packaging config had no
entry for the faster-whisper sidecar and no pipeline step built one, so the app
always fell back to a system Python without the runtime. Development was broken
too, because the sidecar and SoX paths were resolved against the Vite output
directory instead of the app root, which also meant recording failed with a SoX
ENOENT. On hosts where localhost resolves only to IPv6, every local request was
refused outright, which silently disabled both local transcription and the local
LLM.

The sidecar is now built and bundled (including the Silero VAD data it needs),
gated by a packaging check that fails when the engine or its data is missing.
Paths are discovered from the app root and fail loudly when the engine is
absent. Local engine URLs are normalized to the IPv4 loopback, decoding is tuned
so repeated hallucinations cannot compound (the same transcript now takes about
a fifth of the time), the engine is warmed up at startup, and holding the hotkey
now shows the text forming live in the recording tip.
2026-09-18 00:48:47 +09:00

390 lines
14 KiB
TypeScript

// src/main/bootstrap.ts — 초기화 시퀀스
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
import { initLoggerService, getLogger } from './services/LoggerService'
import { initConfigService, configGet, configSet } from './services/ConfigService'
import { getHotkeyService } from './services/HotkeyService'
import { getVoiceModeService } from './services/VoiceModeService'
import { startLocalLLMAvailability } from './services/LocalLLMService'
import { getHistoryService } from './services/HistoryService'
import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
import { getTextInsertService } from './services/TextInsertService'
import { getCustomInstructionService } from './services/CustomInstructionService'
import { getVoiceCommandService } from './services/VoiceCommandService'
import { getSoundEffectService } from './services/SoundEffectService'
import { getAutoLaunchService } from './services/AutoLaunchService'
import { getAudioCaptureService } from './services/AudioCaptureService'
import { initLicenseService } from './services/LicenseService'
import { openLocal } from './db'
import {
createMainWindow,
getMainWindow,
preloadPopupWindows,
showHistoryPopup,
hideHistoryPopup,
sendKeyToHistoryPopup,
isHistoryPopupVisible,
showCommandPopup,
hideCommandPopup,
sendKeyToCommandPopup,
isCommandPopupVisible,
hideRecordingTip,
updateRecordingTipState,
} from './windows/WindowManager'
import { createTray } from './windows/TrayManager'
import { registerAllIpcHandlers } from './ipc'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import type { IPCChannel } from '@d3ro/core/ipc-channels'
const logger = getLogger('bootstrap')
interface BootstrapStep {
name: string
critical: boolean
fn: () => Promise<void>
}
export async function bootstrap(): Promise<void> {
const steps: BootstrapStep[] = [
{ name: 'logger', critical: false, fn: initLogger },
{ name: 'config', critical: false, fn: initConfig },
// 빅뱅 Phase 1.5: 앱 시작 시 익명 로컬 DB를 먼저 연다.
// 무료 로컬 모드가 사용자 entry point — 회원가입 없이 바로 킬러 피처 사용 가능.
// CloudSyncService._onAuthenticated()에서 로그인 시점에 user DB로 스위치한다.
{ name: 'database', critical: true, fn: initLocalDatabase },
{ name: 'license', critical: false, fn: initLicense },
{ name: 'create-windows', critical: true, fn: createWindows },
{ name: 'tray', critical: false, fn: initTray },
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
{ name: 'custom-instructions', critical: false, fn: initCustomInstructions },
{ name: 'voice-commands', critical: false, fn: initVoiceCommands },
{ name: 'sound-effects', critical: false, fn: initSoundEffects },
{ name: 'auto-launch', critical: false, fn: initAutoLaunch },
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
{ name: 'hotkey', critical: false, fn: initHotkey },
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
{ name: 'stt-warmup', critical: false, fn: initSTTWarmup },
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
{ name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring },
{ name: 'meeting-mode', critical: false, fn: initMeetingMode },
// cloud-sync는 가장 마지막에 — 세션 복원 성공 시 DB를 열고 auth-changed emit.
{ name: 'cloud-sync', critical: false, fn: initCloudSync },
{ name: 'auto-update', critical: false, fn: initAutoUpdate },
]
for (const step of steps) {
try {
await step.fn()
logger.info(`[bootstrap] ${step.name} initialized`)
} catch (error) {
logger.error(`[bootstrap] ${step.name} failed:`, error)
if (step.critical) {
dialog.showErrorBox(
'D3RO-VOICE 초기화 실패',
`${step.name}: ${error instanceof Error ? error.message : String(error)}`
)
app.quit()
return
}
}
}
}
async function initLogger(): Promise<void> {
initLoggerService()
}
async function initConfig(): Promise<void> {
initConfigService()
}
async function initLocalDatabase(): Promise<void> {
const { created, dbPath } = openLocal()
logger.info(`[bootstrap] local database opened: ${dbPath} (created=${created})`)
}
async function initLicense(): Promise<void> {
initLicenseService()
}
async function createWindows(): Promise<void> {
createMainWindow()
}
async function initTray(): Promise<void> {
createTray()
}
async function initIpcHandlers(): Promise<void> {
registerAllIpcHandlers()
}
async function initHotkey(): Promise<void> {
const hotkey = getHotkeyService()
hotkey.loadFromConfig()
hotkey.start()
}
async function initCustomInstructions(): Promise<void> {
getCustomInstructionService().initialize()
}
async function initVoiceCommands(): Promise<void> {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
}
async function initSoundEffects(): Promise<void> {
getSoundEffectService().initialize()
}
async function initAutoLaunch(): Promise<void> {
getAutoLaunchService().syncWithConfig()
}
async function initAutoUpdate(): Promise<void> {
const { getUpdateService } = await import('./services/UpdateService')
getUpdateService().initialize()
}
async function initPopupWindows(): Promise<void> {
preloadPopupWindows()
setupHistoryPopupIPC()
// 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환)
getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ })
// Ctrl+Shift+V → 히스토리 팝업 토글
globalShortcut.register('Ctrl+Shift+V', () => {
if (isHistoryPopupVisible()) {
hideHistoryPopup()
unregisterPopupNavKeys()
} else {
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
registerPopupNavKeys()
}
})
// Ctrl+Shift+C → 커맨드 선택 팝업 토글
globalShortcut.register('Ctrl+Shift+C', () => {
if (isCommandPopupVisible()) {
hideCommandPopup()
unregisterPopupNavKeys()
} else {
const instructions = getCustomInstructionService().getAll()
const activeId = configGet('activeInstructionId') as string | null
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
registerPopupNavKeys('command')
}
})
setupCommandPopupIPC()
}
// 히스토리 팝업 키 네비게이션 등록/해제
const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [
{ accel: 'Up', key: 'ArrowUp' },
{ accel: 'Down', key: 'ArrowDown' },
{ accel: 'Return', key: 'Enter' },
{ accel: 'Escape', key: 'Escape' },
{ accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' },
{ accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' },
{ accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' },
]
function registerPopupNavKeys(target: 'history' | 'command' = 'history'): void {
for (const { accel, key } of POPUP_NAV_KEYS) {
try {
globalShortcut.register(accel, () => {
if (target === 'history' && isHistoryPopupVisible()) {
sendKeyToHistoryPopup(key)
} else if (target === 'command' && isCommandPopupVisible()) {
sendKeyToCommandPopup(key)
}
})
} catch {
// 일부 키는 globalShortcut으로 등록 불가할 수 있음
}
}
}
function unregisterPopupNavKeys(): void {
for (const { accel } of POPUP_NAV_KEYS) {
try {
globalShortcut.unregister(accel)
} catch {
// 이미 해제된 경우 무시
}
}
}
async function initVoiceMode(): Promise<void> {
const voiceMode = getVoiceModeService()
voiceMode.connectHotkey()
const soundEffect = getSoundEffectService()
// 효과음 연동 (팝업 제어는 VoiceModeService 내부에서 처리)
voiceMode.on('session-started', () => {
soundEffect.play('recording-start')
})
voiceMode.on('session-completed', ({ session, finalText }) => {
soundEffect.play('recording-stop')
persistCompletedVoiceSessionSafe(
(error, failedSession) => {
voiceMode.emit('error', { error, session: failedSession })
},
session,
finalText,
)
notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'session-completed' })
})
voiceMode.on('session-cancelled', ({ reason }) => {
if (reason !== 'too-short') {
soundEffect.play('cancel')
}
hideRecordingTip()
})
voiceMode.on('error', ({ error }) => {
soundEffect.play('error')
updateRecordingTipState('error', { errorMessage: error.message })
setTimeout(() => hideRecordingTip(), 3000)
})
}
/** 메인 윈도우 렌더러에 UI 갱신 이벤트 전송 */
function notifyRenderer(channel: IPCChannel, data?: Record<string, unknown>): void {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send(channel, data ?? {})
}
}
async function initLLMPolling(): Promise<void> {
await startLocalLLMAvailability()
}
/**
* 로컬 STT(sidecar + Whisper 모델)를 앱 시작 시 백그라운드로 미리 데운다.
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리는 체감 지연을 없앤다.
* bootstrap을 막지 않도록 await하지 않는다 — 실패는 warmUpLocal이 흡수한다.
*/
async function initSTTWarmup(): Promise<void> {
try {
const { getSTTManager } = await import('./services/stt/STTManager')
void getSTTManager().warmUpLocal()
} catch (err) {
logger.warn('STT warmup scheduling failed:', err)
}
}
async function initCloudSync(): Promise<void> {
const { getCloudSyncService } = await import('./services/CloudSyncService')
const sync = getCloudSyncService()
await sync.init()
}
async function initMeetingSummaryWiring(): Promise<void> {
try {
const { getCaptionService } = await import('./services/CaptionService')
const { getMeetingSummaryService } = await import('./services/MeetingSummaryService')
const captionService = getCaptionService()
const summaryService = getMeetingSummaryService()
captionService.on('session-saved', (summary: { sessionId: string }) => {
// 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기
import('./services/MeetingModeService').then(({ getMeetingModeService }) => {
if (getMeetingModeService().isMeetingModeActive()) {
logger.info('Skipping auto meeting summary: meeting mode active')
return
}
summaryService.onCaptionSessionSaved(summary).catch((err) => {
logger.warn('Auto meeting summary failed:', err)
})
}).catch(() => {
// MeetingModeService 로드 실패 시 기본 동작 수행
summaryService.onCaptionSessionSaved(summary).catch((err) => {
logger.warn('Auto meeting summary failed:', err)
})
})
})
} catch (err) {
logger.warn('Meeting summary wiring failed:', err)
}
}
async function initMeetingMode(): Promise<void> {
// MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성)
const { getMeetingModeService } = await import('./services/MeetingModeService')
getMeetingModeService()
// MeetingDocTemplateService 싱글톤 초기화 (빌트인 템플릿 보장)
const { getMeetingDocTemplateService } = await import('./services/MeetingDocTemplateService')
getMeetingDocTemplateService()
}
// ── HistoryPopup IPC 연동 ────────────────────────────
function setupHistoryPopupIPC(): void {
// 히스토리 팝업 열기 (핫키에서 호출)
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => {
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
})
// 아이템 선택 → 텍스트 삽입
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.ITEM_SELECTED, (_event: Electron.IpcMainEvent, data: { text: string }) => {
hideHistoryPopup()
unregisterPopupNavKeys()
setTimeout(async () => {
try {
await getTextInsertService().insertText(data.text)
} catch (error) {
logger.warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`)
}
}, 200)
})
// 팝업 닫기
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => {
hideHistoryPopup()
unregisterPopupNavKeys()
})
}
// ── CommandPopup IPC 연동 ────────────────────────────
function setupCommandPopupIPC(): void {
// 명령어 선택 → 활성 명령어로 설정
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.SELECTED, (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => {
hideCommandPopup()
unregisterPopupNavKeys()
if (data.id) {
// 명령어 선택 → 활성 명령어로 설정
configSet('activeInstructionId', data.id)
configSet('defaultLLMAction', 'custom')
logger.info(`Active command set: ${data.name} (${data.id})`)
} else {
// 선택 해제 → 명령어 없음 (원본 삽입)
configSet('activeInstructionId', '')
configSet('defaultLLMAction', 'none')
logger.info('Active command cleared (none)')
}
// CMD 페이지 UI 갱신 알림
notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'command-changed', activeId: data.id })
})
// 팝업 닫기
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => {
hideCommandPopup()
unregisterPopupNavKeys()
})
}