Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터

Phase 1:
- 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier)
- shared 타입 (ipc-channels 113채널, types, errors, constants)
- 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스)
- LoggerService, ConfigService (electron-store ESM dynamic import)
- React 19 + MUI 7 Dashboard, 시스템 트레이

Phase 2:
- AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono)
- HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode)
- LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시)
- VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue)
- Python sidecar (FastAPI: health/load/transcribe/shutdown)
- IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
Yun Chan 2026-04-05 02:01:37 +09:00
parent e24bb8378c
commit 1d152d01a1
46 changed files with 10828 additions and 4 deletions

26
src/renderer/App.tsx Normal file
View file

@ -0,0 +1,26 @@
// src/renderer/App.tsx — 루트 컴포넌트
import { useState, useMemo } from 'react'
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material'
import { lightTheme, darkTheme } from './theme'
import { AppLayout } from './components/AppLayout'
import type { ThemeMode } from '@shared/types'
export function App(): React.ReactElement {
const [themeMode] = useState<ThemeMode>('auto')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const theme = useMemo(() => {
if (themeMode === 'auto') {
return prefersDark ? darkTheme : lightTheme
}
return themeMode === 'dark' ? darkTheme : lightTheme
}, [themeMode, prefersDark])
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<AppLayout />
</ThemeProvider>
)
}

View file

@ -0,0 +1,110 @@
// src/renderer/components/AppLayout.tsx
import { useState } from 'react'
import {
Box,
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Divider,
Typography,
Chip
} from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import HistoryIcon from '@mui/icons-material/History'
import MenuBookIcon from '@mui/icons-material/MenuBook'
import SettingsIcon from '@mui/icons-material/Settings'
import { DashboardPage } from '../pages/DashboardPage'
type Route = 'dashboard' | 'history' | 'dictionary'
const DRAWER_WIDTH = 240
const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [
{ route: 'dashboard', label: 'Dashboard', icon: <DashboardIcon /> },
{ route: 'history', label: 'History', icon: <HistoryIcon /> },
{ route: 'dictionary', label: 'Dictionary', icon: <MenuBookIcon /> }
]
export function AppLayout(): React.ReactElement {
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
return (
<Box sx={{ display: 'flex', height: '100vh' }}>
{/* Sidebar Drawer */}
<Drawer
variant="permanent"
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box'
}
}}
>
{/* Header */}
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" noWrap sx={{ fontWeight: 700 }}>
D3RO Voice
</Typography>
<Chip label="v1.0" size="small" variant="outlined" />
</Box>
<Divider />
{/* Navigation */}
<List sx={{ flex: 1, pt: 1 }}>
{NAV_ITEMS.map((item) => (
<ListItemButton
key={item.route}
selected={currentRoute === item.route}
onClick={() => setCurrentRoute(item.route)}
sx={{ my: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
))}
</List>
<Divider />
{/* Bottom */}
<List>
<ListItemButton sx={{ my: 0.5 }}>
<ListItemIcon sx={{ minWidth: 40 }}>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItemButton>
</List>
</Drawer>
{/* Content Area */}
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
overflow: 'auto',
bgcolor: 'background.default'
}}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && (
<Typography variant="h5" color="text.secondary">
History (Phase 5)
</Typography>
)}
{currentRoute === 'dictionary' && (
<Typography variant="h5" color="text.secondary">
Dictionary (Phase 5)
</Typography>
)}
</Box>
</Box>
)
}

8
src/renderer/electron.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
// src/renderer/electron.d.ts
import type { ElectronAPI } from '../preload/index'
declare global {
interface Window {
electronAPI: ElectronAPI
}
}

12
src/renderer/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>D3RO Voice</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

14
src/renderer/main.tsx Normal file
View file

@ -0,0 +1,14 @@
// src/renderer/main.tsx — 렌더러 진입점
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
const root = document.getElementById('root')
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
)
}

View file

@ -0,0 +1,67 @@
// src/renderer/pages/DashboardPage.tsx
import { Box, Card, CardContent, Typography, Grid } from '@mui/material'
import MicIcon from '@mui/icons-material/Mic'
import TimerIcon from '@mui/icons-material/Timer'
import TextFieldsIcon from '@mui/icons-material/TextFields'
import TodayIcon from '@mui/icons-material/Today'
interface StatCardProps {
title: string
value: string
icon: React.ReactElement
}
function StatCard({ title, value, icon }: StatCardProps): React.ReactElement {
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Box sx={{ color: 'primary.main' }}>{icon}</Box>
<Typography variant="body2" color="text.secondary">
{title}
</Typography>
</Box>
<Typography variant="h4">{value}</Typography>
</CardContent>
</Card>
)
}
export function DashboardPage(): React.ReactElement {
return (
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
Dashboard
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard title="Total Sessions" value="0" icon={<MicIcon />} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard title="Total Time" value="0:00" icon={<TimerIcon />} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard title="Total Words" value="0" icon={<TextFieldsIcon />} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard title="Streak" value="0 days" icon={<TodayIcon />} />
</Grid>
</Grid>
<Box sx={{ mt: 4 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Recent Sessions
</Typography>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No sessions yet. Press the hotkey to start recording.
</Typography>
</CardContent>
</Card>
</Box>
</Box>
)
}

148
src/renderer/theme.ts Normal file
View file

@ -0,0 +1,148 @@
// src/renderer/theme.ts — MUI 7 테마 정의 (설계서 03 기반)
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'
}
}
},
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
}
}
},
MuiTextField: {
defaultProps: {
size: 'small',
variant: 'outlined'
}
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 6,
fontWeight: 500
}
}
}
}
}
export const lightTheme = createTheme({
...commonOptions,
palette: {
mode: 'light',
primary: {
main: 'rgb(31, 93, 242)',
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' },
success: { main: '#2E7D32' },
warning: { main: '#ED6C02' }
}
})
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' },
success: { main: '#4CAF50' },
warning: { main: '#FFA726' }
}
})
export function getTheme(mode: 'light' | 'dark') {
return mode === 'dark' ? darkTheme : lightTheme
}