- CLAUDE.md: 프로젝트 규칙, 기술 스택, 코딩 규칙, 페이즈 로드맵 - .claude/settings.json: 권한, 강제 훅 (매 프롬프트 설계서 규칙 주입) - .claude/skills/: implement-phase, review-phase, scaffold, test-commit, debug - .claude/agents/: electron-architect, voice-pipeline-expert, ui-specialist - docs/design/00-09: 마스터 아키텍처, 서비스 명세(16개), IPC(113채널), DB스키마, UI컴포넌트, 검증리포트, 외부엔진연동, 갭분석, VoiceMode패턴, 디자인시스템, 히스토리팝업 - docs/phases/1-7+3.5: 전체 구현 페이즈 문서 - docs/re-findings/: Speakly RE 노하우 5개 문서
35 KiB
03. DB 스키마 & UI 컴포넌트 명세서
1. SQLite 데이터베이스 스키마
D3RO-VOICE는 better-sqlite3 + drizzle-orm을 사용한다. Speakly의 genspark-flow.db 스키마를 참조하되, 클라우드 관련 컬럼은 제거하고 로컬 전용 필드를 추가한다.
1.1 history 테이블
세션(녹음 → 전사 → 다듬기) 단위로 한 행씩 저장한다.
CREATE TABLE history (
id TEXT PRIMARY KEY, -- nanoid, sessionId와 동일
original_text TEXT NOT NULL, -- STT 원본 전사 텍스트
polished_text TEXT, -- LLM 다듬기/번역 결과 (NULL = 미사용)
focused_app TEXT, -- 포커스 앱 실행 경로
focused_app_name TEXT, -- 포커스 앱 이름 (예: 'Code')
focused_app_window_title TEXT, -- 포커스 윈도우 타이틀
mode TEXT NOT NULL DEFAULT 'dictation', -- 'dictation' | 'translate' | 'command'
status TEXT NOT NULL DEFAULT 'completed', -- 'completed' | 'cancelled' | 'error'
error_code TEXT, -- 실패 시 에러 코드 (D3ROError 코드)
audio_local_path TEXT, -- 녹음 파일 경로 (NULL = 저장 안 함)
duration REAL NOT NULL, -- 녹음 시간(초)
detected_language TEXT, -- Whisper 감지 언어 코드 (예: 'ko', 'en')
mic_device TEXT, -- 마이크 디바이스 ID
word_count INTEGER NOT NULL DEFAULT 0, -- 단어 수
stt_model TEXT, -- 사용된 Whisper 모델명
llm_model TEXT, -- 사용된 Ollama 모델명
stt_latency_ms INTEGER, -- STT 처리 시간(ms)
llm_latency_ms INTEGER, -- LLM 처리 시간(ms)
created_at INTEGER NOT NULL, -- Unix timestamp (ms)
updated_at INTEGER NOT NULL, -- Unix timestamp (ms)
app_version TEXT NOT NULL DEFAULT '1.0.0'
);
CREATE INDEX idx_history_created_at ON history(created_at DESC);
CREATE INDEX idx_history_status ON history(status);
CREATE INDEX idx_history_detected_language ON history(detected_language);
CREATE INDEX idx_history_focused_app_name ON history(focused_app_name);
CREATE INDEX idx_history_mode ON history(mode);
Speakly 대비 변경점:
- 제거:
user_id,focused_app_bundle_id(macOS 전용),window_web_title/domain/url,audio_metadata,mic_device_info,selected_text - 추가:
error_code,stt_model,llm_model,stt_latency_ms,llm_latency_ms - 인덱스:
user_id복합 인덱스 → 단일 컬럼 인덱스로 단순화 (로컬 단일 사용자)
1.2 dictionary 테이블
사용자 커스텀 단어 사전. STT 정확도 향상 및 자동 교정에 사용한다.
CREATE TABLE dictionary (
id TEXT PRIMARY KEY, -- nanoid
word TEXT NOT NULL, -- 단어/구문
pronunciation TEXT, -- 발음 힌트 (선택)
category TEXT NOT NULL DEFAULT 'user', -- 'user' | 'auto' | 'technical'
usage_count INTEGER NOT NULL DEFAULT 0, -- 사용 횟수
last_used_at INTEGER, -- 마지막 사용 시각 (Unix timestamp ms)
created_at INTEGER NOT NULL, -- Unix timestamp (ms)
updated_at INTEGER NOT NULL -- Unix timestamp (ms)
);
CREATE UNIQUE INDEX idx_dictionary_word_category ON dictionary(word, category);
CREATE INDEX idx_dictionary_created_at ON dictionary(created_at);
CREATE INDEX idx_dictionary_usage_count ON dictionary(usage_count DESC);
Speakly 대비 변경점:
- 제거:
user_id,dict_type→category로 통합 (로컬 전용이므로) - 제거:
dict_sync_meta테이블 전체 (클라우드 동기화 불필요) - 추가:
idx_dictionary_usage_count인덱스 (자주 쓰는 단어 우선)
1.3 stats 테이블
전역 통계. 싱글턴 row (id=1)로 운영한다.
CREATE TABLE stats (
id INTEGER PRIMARY KEY CHECK (id = 1), -- 항상 1
total_duration REAL NOT NULL DEFAULT 0, -- 누적 녹음 시간(초)
total_words INTEGER NOT NULL DEFAULT 0, -- 누적 단어 수
session_count INTEGER NOT NULL DEFAULT 0, -- 누적 세션 수
streak_days INTEGER NOT NULL DEFAULT 0, -- 연속 사용 일수
last_session_at INTEGER, -- 마지막 세션 시각
last_updated INTEGER NOT NULL -- Unix timestamp (ms)
);
Speakly 대비 변경점:
- 추가:
streak_days,last_session_at(대시보드 통계용) CHECK (id = 1)제약으로 싱글턴 보장
1.4 config 테이블
키-값 저장소. electron-store의 JSON 파일 대안으로 사용하지 않고, 이 프로젝트에서는 electron-store를 그대로 사용한다 (CLAUDE.md 기술 스택). config 테이블은 정의하지 않는다.
참고: 설정은
electron-store로 관리한다. DB에 별도 config 테이블을 두지 않는 이유:
- electron-store는 JSON 기반으로 중첩 구조를 자연스럽게 지원
- 스키마 검증, 기본값, 마이그레이션을 라이브러리가 처리
- 설정 파일은 사용자가 직접 편집 가능 (config.json)
1.5 drizzle-orm TypeScript 스키마
// src/main/db/schema.ts
import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
// ── history ──────────────────────────────────────────────
export const history = sqliteTable('history', {
id: text('id').primaryKey(),
originalText: text('original_text').notNull(),
polishedText: text('polished_text'),
focusedApp: text('focused_app'),
focusedAppName: text('focused_app_name'),
focusedAppWindowTitle: text('focused_app_window_title'),
mode: text('mode', { enum: ['dictation', 'translate', 'command'] }).notNull().default('dictation'),
status: text('status', { enum: ['completed', 'cancelled', 'error'] }).notNull().default('completed'),
errorCode: text('error_code'),
audioLocalPath: text('audio_local_path'),
duration: real('duration').notNull(),
detectedLanguage: text('detected_language'),
micDevice: text('mic_device'),
wordCount: integer('word_count').notNull().default(0),
sttModel: text('stt_model'),
llmModel: text('llm_model'),
sttLatencyMs: integer('stt_latency_ms'),
llmLatencyMs: integer('llm_latency_ms'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
appVersion: text('app_version').notNull().default('1.0.0'),
}, (table) => [
index('idx_history_created_at').on(table.createdAt),
index('idx_history_status').on(table.status),
index('idx_history_detected_language').on(table.detectedLanguage),
index('idx_history_focused_app_name').on(table.focusedAppName),
index('idx_history_mode').on(table.mode),
]);
// ── dictionary ───────────────────────────────────────────
export const dictionary = sqliteTable('dictionary', {
id: text('id').primaryKey(),
word: text('word').notNull(),
pronunciation: text('pronunciation'),
category: text('category', { enum: ['user', 'auto', 'technical'] }).notNull().default('user'),
usageCount: integer('usage_count').notNull().default(0),
lastUsedAt: integer('last_used_at'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
}, (table) => [
uniqueIndex('idx_dictionary_word_category').on(table.word, table.category),
index('idx_dictionary_created_at').on(table.createdAt),
index('idx_dictionary_usage_count').on(table.usageCount),
]);
// ── stats ────────────────────────────────────────────────
export const stats = sqliteTable('stats', {
id: integer('id').primaryKey(),
totalDuration: real('total_duration').notNull().default(0),
totalWords: integer('total_words').notNull().default(0),
sessionCount: integer('session_count').notNull().default(0),
streakDays: integer('streak_days').notNull().default(0),
lastSessionAt: integer('last_session_at'),
lastUpdated: integer('last_updated').notNull(),
});
// ── 타입 추출 ────────────────────────────────────────────
export type History = typeof history.$inferSelect;
export type NewHistory = typeof history.$inferInsert;
export type Dictionary = typeof dictionary.$inferSelect;
export type NewDictionary = typeof dictionary.$inferInsert;
export type Stats = typeof stats.$inferSelect;
1.6 DB 초기화 코드
// src/main/db/index.ts
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { app } from 'electron';
import path from 'path';
import * as schema from './schema';
let db: ReturnType<typeof drizzle>;
export function initDatabase(): typeof db {
const dbPath = path.join(app.getPath('userData'), 'd3ro-voice.db');
const sqlite = new Database(dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('busy_timeout = 5000');
db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: path.join(__dirname, 'migrations') });
// stats 싱글턴 초기화
sqlite.exec(`
INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated)
VALUES (1, 0, 0, 0, 0, ${Date.now()})
`);
return db;
}
export function getDatabase(): typeof db {
return db;
}
2. React 컴포넌트 명세
2.1 컴포넌트 트리
App
├── ThemeProvider (light/dark/auto)
│ ├── CssBaseline
│ └── AppLayout
│ ├── AppDrawer (240px, permanent variant)
│ │ ├── DrawerHeader (로고 + 앱 이름)
│ │ ├── NavItems
│ │ │ ├── NavItem[Dashboard]
│ │ │ ├── NavItem[History]
│ │ │ ├── NavItem[Dictionary]
│ │ │ └── NavItem[CustomCommand]
│ │ └── DrawerBottomBar
│ │ └── SettingsButton (→ Settings Modal)
│ └── ContentArea
│ ├── Dashboard
│ │ ├── StatsCard (총 녹음시간, 총 단어수, 세션수, 연속일)
│ │ ├── RecentSessions (최근 5개)
│ │ └── QuickActions (녹음 시작 버튼)
│ ├── HistoryPage
│ │ ├── SearchBar
│ │ ├── FilterBar (mode, status, language)
│ │ ├── HistoryList
│ │ │ └── HistoryItem (원본/다듬기 텍스트, 메타데이터)
│ │ └── Pagination
│ ├── DictionaryPage
│ │ ├── SearchBar
│ │ ├── AddWordDialog
│ │ └── WordList
│ │ └── WordItem (단어, 발음, 카테고리, 사용횟수)
│ ├── CustomCommandPage
│ │ ├── CommandList
│ │ └── CommandEditor
│ └── SettingsModal
│ ├── GeneralTab (테마, 언어, 자동실행)
│ ├── AudioTab (마이크 선택, 녹음 설정)
│ ├── SttTab (Whisper 모델 선택, 언어)
│ ├── LlmTab (Ollama 엔드포인트, 모델)
│ ├── TtsTab (Kokoro TTS 음성 선택, 속도, edge-tts 폴백)
│ ├── HotkeyTab (단축키 설정)
│ └── AboutTab (버전, 라이선스)
2.2 컴포넌트 상세 명세
App (루트)
// src/renderer/components/App.tsx
interface AppState {
currentRoute: 'dashboard' | 'history' | 'dictionary' | 'customCommand';
settingsOpen: boolean;
theme: 'light' | 'dark' | 'auto';
}
| 항목 | 내용 |
|---|---|
| Props | 없음 (루트) |
| 내부 상태 | currentRoute, settingsOpen, theme |
| IPC 호출 | config:getTheme (초기화 시) |
| 이벤트 구독 | config:themeChanged |
| 렌더링 조건 | currentRoute로 switch 분기 (React Router 미사용) |
| MUI 컴포넌트 | ThemeProvider, CssBaseline, Box |
AppDrawer
interface AppDrawerProps {
currentRoute: string;
onNavigate: (route: string) => void;
onSettingsOpen: () => void;
}
| 항목 | 내용 |
|---|---|
| Props | currentRoute, onNavigate, onSettingsOpen |
| 내부 상태 | 없음 |
| IPC 호출 | 없음 |
| 이벤트 구독 | 없음 |
| MUI 컴포넌트 | Drawer (permanent, 240px), List, ListItemButton, ListItemIcon, ListItemText, Divider |
Dashboard
interface DashboardProps {
// 없음
}
interface DashboardState {
stats: Stats | null;
recentSessions: History[];
isLoading: boolean;
}
| 항목 | 내용 |
|---|---|
| Props | 없음 |
| 내부 상태 | stats, recentSessions, isLoading |
| IPC 호출 | db:getStats, db:getRecentHistory(5) |
| 이벤트 구독 | voice:sessionCompleted (실시간 갱신) |
| 렌더링 조건 | isLoading → Skeleton, stats === null → 초기화 안내 |
| MUI 컴포넌트 | Card, CardContent, Typography, Grid2, Skeleton, Button, Chip |
HistoryPage
interface HistoryPageState {
items: History[];
total: number;
page: number;
pageSize: number;
search: string;
filters: {
mode: string | null;
status: string | null;
language: string | null;
};
isLoading: boolean;
}
| 항목 | 내용 |
|---|---|
| Props | 없음 |
| 내부 상태 | items, total, page, pageSize, search, filters, isLoading |
| IPC 호출 | db:searchHistory({ search, filters, page, pageSize }), db:deleteHistory(id), db:retrySession(id) |
| 이벤트 구독 | voice:sessionCompleted (리스트 갱신) |
| 렌더링 조건 | isLoading → Skeleton, items.length === 0 → EmptyState |
| MUI 컴포넌트 | TextField (검색), Select/MenuItem (필터), List, ListItem, IconButton, Pagination, Tooltip |
HistoryItem
interface HistoryItemProps {
item: History;
onDelete: (id: string) => void;
onRetry: (id: string) => void;
onCopy: (text: string) => void;
}
| 항목 | 내용 |
|---|---|
| Props | item, onDelete, onRetry, onCopy |
| 내부 상태 | expanded: boolean, copied: boolean |
| IPC 호출 | 없음 (부모 위임) |
| 렌더링 조건 | item.polishedText 존재 시 원본/다듬기 둘 다 표시, item.status === 'error' → 에러 칩 + 재시도 버튼 |
| MUI 컴포넌트 | ListItem, ListItemText, Collapse, Chip, IconButton, Typography |
DictionaryPage
interface DictionaryPageState {
words: Dictionary[];
search: string;
addDialogOpen: boolean;
isLoading: boolean;
}
| 항목 | 내용 |
|---|---|
| Props | 없음 |
| 내부 상태 | words, search, addDialogOpen, isLoading |
| IPC 호출 | db:getDictionary({ search }), db:addWord(word), db:deleteWord(id), db:updateWord(id, data) |
| 이벤트 구독 | 없음 |
| MUI 컴포넌트 | TextField, Button, Dialog, DialogTitle, DialogContent, DialogActions, List, ListItem, IconButton |
SettingsModal
interface SettingsModalProps {
open: boolean;
onClose: () => void;
}
interface SettingsModalState {
activeTab: 'general' | 'audio' | 'stt' | 'llm' | 'tts' | 'hotkey' | 'about';
config: Record<string, unknown>;
isDirty: boolean;
}
| 항목 | 내용 |
|---|---|
| Props | open, onClose |
| 내부 상태 | activeTab, config, isDirty |
| IPC 호출 | config:getAll, config:set(key, value), audio:getDevices, stt:getModels, llm:getModels, tts:getVoices |
| 이벤트 구독 | audio:devicesChanged |
| 렌더링 조건 | activeTab으로 탭 패널 분기 |
| MUI 컴포넌트 | Dialog (fullWidth, maxWidth='md'), Tabs, Tab, TabPanel (커스텀), TextField, Select, Switch, Slider, Button |
3. Vanilla JS 팝업 명세
팝업은 React 번들을 로드하지 않는다. 개별 HTML + 순수 JS로 빠른 로딩을 보장한다.
3.1 RecordingTip 팝업
녹음 중 커서 근처에 표시되는 소형 팝업. 웨이브 바 애니메이션으로 녹음 상태를 시각적으로 표현한다.
파일 위치: src/renderer/popups/recording-tip/
HTML 구조
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="root">
<div id="container" class="recording-tip">
<!-- recording 상태 -->
<div id="recording-view" class="view">
<div id="wave-bars" class="wave-bars">
<!-- 9개 바: JS로 동적 생성 -->
</div>
<span id="duration-text" class="duration">0:00</span>
</div>
<!-- thinking 상태 -->
<div id="thinking-view" class="view hidden">
<div class="progress-container">
<div id="progress-bar" class="progress-bar"></div>
</div>
<span id="thinking-text" class="thinking-label">처리 중...</span>
</div>
<!-- error 상태 -->
<div id="error-view" class="view hidden">
<span id="error-icon" class="error-icon">!</span>
<span id="error-text" class="error-label"></span>
</div>
</div>
</div>
<script src="./script.js"></script>
</body>
</html>
DOM 이벤트 핸들러
| 이벤트 | 대상 | 동작 |
|---|---|---|
DOMContentLoaded |
window |
웨이브 바 9개 생성, IPC 리스너 등록 |
click |
#container |
녹음 취소 IPC 전송 |
IPC 통신 채널
| 채널 | 방향 | 데이터 | 용도 |
|---|---|---|---|
tip:prepare |
main→renderer | { state, params } |
상태 전환 준비 (숨겨진 상태에서 측정) |
tip:show |
main→renderer | { state } |
리사이즈 완료 후 표시 |
tip:hide |
main→renderer | — | 팝업 숨기기 |
tip:audioLevel |
main→renderer | { level: number } |
오디오 레벨 (0.0~1.0) |
tip:measured |
renderer→main | { width, height } |
측정된 콘텐츠 크기 전달 |
voice:cancel |
renderer→main | — | 녹음 취소 요청 |
상태별 렌더링
recording → #recording-view 표시, 웨이브 바 애니메이션 활성, 경과 시간 카운터
thinking → #thinking-view 표시, 프로그레스 바 애니메이션
error → #error-view 표시, 에러 메시지, 3초 후 자동 숨김
웨이브 바 애니메이션 상세
// 파라미터
const BAR_COUNT = 9;
const UPDATE_INTERVAL = 100; // ms
const MIN_HEIGHT = 2; // px
const MAX_HEIGHT = 28; // px
const SMOOTHING = 0.5; // 보간 계수
const RANDOM_FACTOR = 0.35; // ±35% 변동
// 코사인 분포 가중치 (중앙이 가장 높음)
const weights = Array.from({ length: BAR_COUNT }, (_, n) => {
const center = (BAR_COUNT - 1) / 2; // 4
const normalized = (n - center) / center; // -1 ~ +1
return Math.cos(normalized * Math.PI / 2);
// 결과: [0, 0.383, 0.707, 0.924, 1, 0.924, 0.707, 0.383, 0]
});
// 매 100ms 마다 실행
let currentHeights = new Array(BAR_COUNT).fill(MIN_HEIGHT);
function updateBars(audioLevel) {
for (let i = 0; i < BAR_COUNT; i++) {
const baseTarget = audioLevel * MAX_HEIGHT * weights[i];
const randomized = baseTarget * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR);
const target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, randomized));
// 스무딩 보간
currentHeights[i] += (target - currentHeights[i]) * SMOOTHING;
bars[i].style.height = `${currentHeights[i]}px`;
}
}
// setInterval(updateBars, UPDATE_INTERVAL) — tip:audioLevel 이벤트 수신 시 audioLevel 갱신
Thinking 프로그레스 바
// 시간 기반 점근 수렴: 95%에서 정체
let thinkingStartTime = 0;
function startThinking() {
thinkingStartTime = performance.now();
requestAnimationFrame(updateThinkingProgress);
}
function updateThinkingProgress() {
const elapsed = (performance.now() - thinkingStartTime) / 1000; // 초
const progress = Math.min(95, (1 - 1 / (1 + 1.5 * elapsed)) * 100);
progressBar.style.width = `${progress}%`;
if (progress < 95) {
requestAnimationFrame(updateThinkingProgress);
}
}
// 완료 시 → 100%로 빠르게 채운 뒤 hide
function completeThinking() {
progressBar.style.transition = 'width 200ms ease-out';
progressBar.style.width = '100%';
setTimeout(() => hide(), 300);
}
CSS 애니메이션 스펙
.recording-tip {
background: rgba(0, 0, 0, 0.85);
border-radius: 8px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
backdrop-filter: blur(10px);
transition: opacity 150ms ease-in-out;
}
.wave-bars {
display: flex;
align-items: center;
gap: 2px;
height: 32px;
}
.wave-bar {
width: 3px;
background: #1F5DF2; /* primary color */
border-radius: 1.5px;
transition: height 100ms ease-out;
}
.progress-bar {
height: 3px;
background: #1F5DF2;
border-radius: 1.5px;
transition: width 100ms linear;
}
.view { display: flex; align-items: center; gap: 8px; }
.view.hidden { display: none; }
3.2 ResultPopup 팝업
전사/다듬기 결과를 표시하는 팝업. 커서 근처에 나타나며 자동으로 사라진다.
파일 위치: src/renderer/popups/result-popup/
HTML 구조
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="root">
<div id="container" class="result-popup">
<div id="result-text" class="result-text"></div>
<div id="actions" class="actions">
<button id="copy-btn" class="action-btn" title="복사">
<svg id="copy-icon"><!-- clipboard icon --></svg>
<svg id="check-icon" class="hidden"><!-- check icon --></svg>
</button>
<button id="retry-btn" class="action-btn" title="재시도">
<svg><!-- refresh icon --></svg>
</button>
</div>
</div>
</div>
<script src="./script.js"></script>
</body>
</html>
DOM 이벤트 핸들러
| 이벤트 | 대상 | 동작 |
|---|---|---|
DOMContentLoaded |
window |
IPC 리스너 등록 |
click |
#copy-btn |
텍스트 클립보드 복사, .copied 클래스 2초 |
click |
#retry-btn |
voice:retry IPC 전송 |
mouseenter |
#container |
auto-close 타이머 일시정지 |
mouseleave |
#container |
auto-close 타이머 재개 |
IPC 통신 채널
| 채널 | 방향 | 데이터 | 용도 |
|---|---|---|---|
result:prepare |
main→renderer | { text, mode } |
결과 텍스트 세팅 + 크기 측정 |
result:show |
main→renderer | — | 리사이즈 완료 후 표시 |
result:hide |
main→renderer | — | 팝업 숨기기 |
result:measured |
renderer→main | { width, height } |
측정된 크기 전달 |
voice:retry |
renderer→main | — | 재시도 요청 |
2-Phase 리사이즈 + 높이 측정
// Phase 1: prepare — 숨겨진 상태에서 콘텐츠 렌더링 후 크기 측정
window.electronAPI.on('result:prepare', ({ text, mode }) => {
resultText.textContent = text;
container.className = `result-popup ${mode}`;
// requestAnimationFrame으로 레이아웃 완료 대기 후 측정
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const rect = container.getBoundingClientRect();
window.electronAPI.send('result:measured', {
width: Math.ceil(rect.width),
height: Math.ceil(rect.height),
});
});
});
});
// Phase 2: show — 메인 프로세스가 윈도우 리사이즈 완료 후 호출
window.electronAPI.on('result:show', () => {
container.classList.add('visible');
startAutoCloseTimer(5000); // 5초 후 자동 닫기
});
복사 버튼 + .copied 클래스
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(resultText.textContent);
copyBtn.classList.add('copied');
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
setTimeout(() => {
copyBtn.classList.remove('copied');
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}, 2000);
});
Auto-close 제어
let autoCloseTimer = null;
let remainingTime = 0;
let lastTick = 0;
function startAutoCloseTimer(ms) {
remainingTime = ms;
lastTick = Date.now();
autoCloseTimer = setInterval(() => {
remainingTime -= (Date.now() - lastTick);
lastTick = Date.now();
if (remainingTime <= 0) {
clearInterval(autoCloseTimer);
window.electronAPI.send('result:hide');
}
}, 100);
}
container.addEventListener('mouseenter', () => {
clearInterval(autoCloseTimer); // 호버 중 타이머 정지
});
container.addEventListener('mouseleave', () => {
startAutoCloseTimer(remainingTime > 0 ? remainingTime : 2000); // 남은 시간 또는 2초
});
CSS 애니메이션
.result-popup {
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
padding: 12px 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
opacity: 0;
transform: translateY(4px);
transition: opacity 200ms ease-out, transform 200ms ease-out;
max-width: 400px;
}
.result-popup.visible {
opacity: 1;
transform: translateY(0);
}
.action-btn { /* ... */ }
.action-btn.copied {
color: #4CAF50;
transition: color 200ms ease;
}
/* 다크모드 */
@media (prefers-color-scheme: dark) {
.result-popup {
background: #1E1E1E;
border-color: rgba(255, 255, 255, 0.08);
}
}
4. MUI 테마 전체 정의
Speakly 테마 분석 결과를 기반으로 한 완전한 createTheme() 코드.
// src/renderer/theme.ts
import { createTheme, type ThemeOptions } from '@mui/material/styles';
const commonOptions: ThemeOptions = {
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
h4: { fontWeight: 600, fontSize: '1.5rem' },
h5: { fontWeight: 600, fontSize: '1.25rem' },
h6: { fontWeight: 600, fontSize: '1rem' },
subtitle1: { fontWeight: 500 },
body1: { fontSize: '0.9375rem' },
body2: { fontSize: '0.8125rem' },
button: { textTransform: 'none' as const, fontWeight: 500 },
},
shape: {
borderRadius: 12,
},
components: {
MuiButton: {
defaultProps: {
disableElevation: true,
},
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 500,
borderRadius: 8,
padding: '8px 16px',
},
containedPrimary: {
'&:hover': { boxShadow: '0 2px 8px rgba(31, 93, 242, 0.3)' },
},
},
},
MuiCard: {
defaultProps: {
elevation: 0,
},
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid',
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
width: 240,
borderRight: 'none',
},
},
},
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: 8,
marginLeft: 8,
marginRight: 8,
'&.Mui-selected': {
fontWeight: 600,
},
},
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 16,
},
},
},
MuiTextField: {
defaultProps: {
size: 'small',
variant: 'outlined',
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 6,
fontWeight: 500,
},
},
},
MuiTooltip: {
defaultProps: {
arrow: true,
},
},
},
};
export const lightTheme = createTheme({
...commonOptions,
palette: {
mode: 'light',
primary: {
main: 'rgb(31, 93, 242)', // #1F5DF2
light: 'rgb(71, 133, 255)',
dark: 'rgb(20, 65, 180)',
contrastText: '#FFFFFF',
},
secondary: {
main: 'rgb(108, 117, 125)',
light: 'rgb(173, 181, 189)',
dark: 'rgb(73, 80, 87)',
},
background: {
default: '#F9F9F9',
paper: '#FFFFFF',
},
text: {
primary: 'rgba(0, 0, 0, 0.87)',
secondary: 'rgba(0, 0, 0, 0.6)',
},
divider: 'rgba(0, 0, 0, 0.08)',
error: {
main: '#D32F2F',
light: '#EF5350',
},
success: {
main: '#2E7D32',
light: '#4CAF50',
},
warning: {
main: '#ED6C02',
},
},
components: {
...commonOptions.components,
MuiCard: {
...commonOptions.components?.MuiCard,
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid rgba(0, 0, 0, 0.08)',
backgroundColor: '#FFFFFF',
},
},
},
},
});
export const darkTheme = createTheme({
...commonOptions,
palette: {
mode: 'dark',
primary: {
main: 'rgb(71, 133, 255)', // 밝은 블루 (다크모드에서 가독성)
light: 'rgb(120, 170, 255)',
dark: 'rgb(31, 93, 242)',
contrastText: '#FFFFFF',
},
secondary: {
main: 'rgb(173, 181, 189)',
light: 'rgb(206, 212, 218)',
dark: 'rgb(108, 117, 125)',
},
background: {
default: '#121212',
paper: '#1E1E1E',
},
text: {
primary: 'rgba(255, 255, 255, 0.87)',
secondary: 'rgba(255, 255, 255, 0.6)',
},
divider: 'rgba(255, 255, 255, 0.08)',
error: {
main: '#EF5350',
light: '#FF7961',
},
success: {
main: '#4CAF50',
light: '#66BB6A',
},
warning: {
main: '#FFA726',
},
},
components: {
...commonOptions.components,
MuiCard: {
...commonOptions.components?.MuiCard,
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid rgba(255, 255, 255, 0.08)',
backgroundColor: '#1E1E1E',
},
},
},
},
});
// 테마 선택 유틸
export function getTheme(mode: 'light' | 'dark'): typeof lightTheme {
return mode === 'dark' ? darkTheme : lightTheme;
}
5. 에러 UI 패턴
5.1 에러 분류
| 카테고리 | 에러 코드 | 재시도 가능 | UI 표시 위치 |
|---|---|---|---|
| STT | STT_MODEL_NOT_FOUND |
X | SettingsModal 유도 |
| STT | STT_PROCESS_CRASHED |
O | RecordingTip → error 상태 |
| STT | STT_TIMEOUT |
O | RecordingTip → error 상태 |
| LLM | LLM_CONNECTION_FAILED |
O | RecordingTip → error 상태 |
| LLM | LLM_MODEL_NOT_FOUND |
X | SettingsModal 유도 |
| LLM | LLM_TIMEOUT |
O | RecordingTip → error 상태 |
| Audio | AUDIO_DEVICE_NOT_FOUND |
X | 시스템 토스트 |
| Audio | AUDIO_PERMISSION_DENIED |
X | 시스템 토스트 + 권한 안내 |
| Audio | AUDIO_TOO_SHORT |
X | RecordingTip → 무시 (자동 숨김) |
| Hotkey | HOTKEY_REGISTER_FAILED |
X | SettingsModal 유도 |
| TextInsert | INSERT_FAILED |
O | ResultPopup → 에러 상태 |
| DB | DB_WRITE_FAILED |
X | 백그라운드 로그만 |
5.2 RecordingTip 에러 표시
function showError(errorCode, message) {
hideAllViews();
errorView.classList.remove('hidden');
errorText.textContent = message;
const isRetryable = RETRYABLE_ERRORS.has(errorCode);
const hideDelay = isRetryable ? 10000 : 3000;
if (isRetryable) {
errorText.textContent += ' (다시 시도해주세요)';
}
setTimeout(() => {
window.electronAPI.send('tip:hide');
}, hideDelay);
}
const RETRYABLE_ERRORS = new Set([
'STT_PROCESS_CRASHED',
'STT_TIMEOUT',
'LLM_CONNECTION_FAILED',
'LLM_TIMEOUT',
'INSERT_FAILED',
]);
5.3 메인 앱 에러 표시 (React)
// Snackbar 기반 토스트 알림
interface ErrorToastProps {
error: { code: string; message: string } | null;
onClose: () => void;
}
// 자동 숨김 타이밍
const AUTO_HIDE_DURATION: Record<string, number> = {
default: 3000, // 일반 에러: 3초
retryable: 10000, // 재시도 가능: 10초
critical: null as never, // 치명적: 수동 닫기만
};
5.4 에러 흐름 요약
에러 발생 (main process)
├── RecordingTip 활성 중?
│ ├── YES → tip:prepare({ state: 'error', params: { code, message } })
│ │ → 3초/10초 후 자동 숨김
│ └── NO → webContents.send('app:error', { code, message })
│ → React Snackbar 표시
│
├── 설정 유도 필요?
│ └── YES → 에러 메시지에 "설정 열기" 액션 버튼 포함
│ → 클릭 시 SettingsModal 해당 탭으로 이동
│
└── 로그 기록 (항상)
→ logger.error({ code, message, stack })
부록: Speakly 원본 스키마 참조
아래는 Speakly genspark-flow.db에서 추출한 원본 스키마이다. D3RO-VOICE 스키마 설계 시 참조 자료로 사용했다.
-- Speakly 원본 (참조용, D3RO-VOICE에서 직접 사용하지 않음)
CREATE TABLE history (
id TEXT PRIMARY KEY,
original_text TEXT NOT NULL,
polished_text TEXT,
focused_app TEXT,
focused_app_name TEXT,
focused_app_bundle_id TEXT,
focused_app_window_title TEXT,
window_web_title TEXT,
window_web_domain TEXT,
window_web_url TEXT,
mode TEXT NOT NULL DEFAULT 'dictation',
status TEXT NOT NULL DEFAULT 'completed',
audio_local_path TEXT,
audio_metadata TEXT,
duration REAL NOT NULL,
detected_language TEXT,
mic_device TEXT,
mic_device_info TEXT,
word_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
user_id TEXT,
app_version TEXT NOT NULL DEFAULT '1.0.0',
selected_text TEXT
);
CREATE TABLE dictionary (
id TEXT PRIMARY KEY,
word TEXT NOT NULL,
pronunciation TEXT,
dict_type TEXT NOT NULL DEFAULT 'user',
user_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used_at INTEGER
);
CREATE TABLE stats (
id INTEGER PRIMARY KEY,
total_duration REAL NOT NULL DEFAULT 0,
total_words INTEGER NOT NULL DEFAULT 0,
session_count INTEGER NOT NULL DEFAULT 0,
last_updated INTEGER NOT NULL
);
CREATE TABLE dict_sync_meta (
user_id TEXT NOT NULL,
dict_type TEXT NOT NULL,
cloud_mtime TEXT,
PRIMARY KEY (user_id, dict_type)
);