- 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개 문서
1131 lines
35 KiB
Markdown
1131 lines
35 KiB
Markdown
# 03. DB 스키마 & UI 컴포넌트 명세서
|
|
|
|
## 1. SQLite 데이터베이스 스키마
|
|
|
|
D3RO-VOICE는 better-sqlite3 + drizzle-orm을 사용한다. Speakly의 `genspark-flow.db` 스키마를 참조하되, 클라우드 관련 컬럼은 제거하고 로컬 전용 필드를 추가한다.
|
|
|
|
### 1.1 history 테이블
|
|
|
|
세션(녹음 → 전사 → 다듬기) 단위로 한 행씩 저장한다.
|
|
|
|
```sql
|
|
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 정확도 향상 및 자동 교정에 사용한다.
|
|
|
|
```sql
|
|
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)로 운영한다.
|
|
|
|
```sql
|
|
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 스키마
|
|
|
|
```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 초기화 코드
|
|
|
|
```typescript
|
|
// 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 (루트)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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 구조
|
|
|
|
```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초 후 자동 숨김
|
|
```
|
|
|
|
#### 웨이브 바 애니메이션 상세
|
|
|
|
```javascript
|
|
// 파라미터
|
|
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 프로그레스 바
|
|
|
|
```javascript
|
|
// 시간 기반 점근 수렴: 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 애니메이션 스펙
|
|
|
|
```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 구조
|
|
|
|
```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 리사이즈 + 높이 측정
|
|
|
|
```javascript
|
|
// 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` 클래스
|
|
|
|
```javascript
|
|
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 제어
|
|
|
|
```javascript
|
|
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 애니메이션
|
|
|
|
```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() 코드.
|
|
|
|
```typescript
|
|
// 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 에러 표시
|
|
|
|
```javascript
|
|
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)
|
|
|
|
```typescript
|
|
// 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 스키마 설계 시 참조 자료로 사용했다.
|
|
|
|
```sql
|
|
-- 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)
|
|
);
|
|
```
|