Phase 9 완료: 전체 남은 기능 구현
1. RecordingTip: thinking 상태 전환 시 opacity 보장 2. 히스토리 오디오 경로: WAV 파일 경로를 DB에 기록 3. 마이크 테스트 UI: Settings Audio 탭에 TEST 버튼 + 레벨 바 4. 온보딩 플로우: 첫 실행 시 3단계 안내 (환영→마이크→핫키→완료) 5. MetalDial 컴포넌트: 동심원 그루브, 회전 포인터, LED 인디케이터
This commit is contained in:
parent
d7074e359d
commit
c338a19c90
7 changed files with 431 additions and 4 deletions
|
|
@ -137,16 +137,21 @@ async function initVoiceMode(): Promise<void> {
|
|||
voiceMode.on('session-completed', ({ session, finalText }) => {
|
||||
soundEffect.play('recording-stop')
|
||||
|
||||
// 이력 저장
|
||||
// 이력 저장 (오디오 파일 경로 포함)
|
||||
try {
|
||||
const { join } = await import('path')
|
||||
const { app } = await import('electron')
|
||||
const wordCount = finalText.split(/\s+/).filter((w) => w.length > 0).length
|
||||
const audioPath = join(app.getPath('userData'), 'recordings', `${session.id}.wav`)
|
||||
|
||||
getHistoryService().create({
|
||||
originalText: session.transcription || finalText,
|
||||
polishedText: session.processedText,
|
||||
mode: session.mode === 'hands-free' ? 'dictation' : session.mode,
|
||||
status: 'completed',
|
||||
duration: (Date.now() - session.startedAt) / 1000,
|
||||
wordCount
|
||||
wordCount,
|
||||
audioLocalPath: audioPath,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to save history: ${error instanceof Error ? error.message : String(error)}`)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/renderer/components/AppLayout.tsx
|
||||
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Typography, Tooltip } from '@mui/material'
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import HistoryIcon from '@mui/icons-material/History'
|
||||
|
|
@ -14,6 +14,7 @@ import { HistoryPage } from '../pages/HistoryPage'
|
|||
import { DictionaryPage } from '../pages/DictionaryPage'
|
||||
import { CommandsPage } from '../pages/CommandsPage'
|
||||
import { SettingsModal } from './SettingsModal'
|
||||
import { OnboardingModal } from './OnboardingModal'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
|
||||
|
|
@ -29,6 +30,19 @@ const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }
|
|||
export function AppLayout(): React.ReactElement {
|
||||
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false)
|
||||
|
||||
// 첫 실행 감지
|
||||
useEffect(() => {
|
||||
window.electronAPI.config.getAll().then((r) => {
|
||||
if (r.success) {
|
||||
const cfg = r.data as Record<string, unknown>
|
||||
if (!cfg['onboardingCompleted']) {
|
||||
setOnboardingOpen(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
|
||||
|
|
@ -161,6 +175,7 @@ export function AppLayout(): React.ReactElement {
|
|||
</Box>
|
||||
<StatusBar />
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
231
src/renderer/components/OnboardingModal.tsx
Normal file
231
src/renderer/components/OnboardingModal.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// src/renderer/components/OnboardingModal.tsx
|
||||
// 첫 실행 시 마이크 + 핫키 설정 안내
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
Chip,
|
||||
} from '@mui/material'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import KeyboardIcon from '@mui/icons-material/Keyboard'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import { Led } from './ds'
|
||||
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
||||
import type { HotkeyBinding, AudioDevice } from '@shared/types'
|
||||
|
||||
interface OnboardingModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
|
||||
const [step, setStep] = useState(0) // 0: 환영, 1: 마이크, 2: 핫키, 3: 완료
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([])
|
||||
const [selectedDevice, setSelectedDevice] = useState('default')
|
||||
const [hotkeyBinding, setHotkeyBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setStep(0)
|
||||
window.electronAPI.audio.getDevices().then((r) => {
|
||||
if (r.success) setDevices(r.data)
|
||||
})
|
||||
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
|
||||
if (r.success && r.data) setHotkeyBinding(r.data)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const handleFinish = () => {
|
||||
// 온보딩 완료 플래그 저장
|
||||
window.electronAPI.config.set({ key: 'onboardingCompleted' as keyof import('@shared/types').AppConfig, value: true as never })
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleHotkeySave = (binding: HotkeyBinding) => {
|
||||
setHotkeyBinding(binding)
|
||||
window.electronAPI.hotkey.setDictationShortcut({ binding })
|
||||
window.electronAPI.hotkey.setEnabled({ enabled: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
backgroundImage: 'none',
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: '0 40px 80px rgba(0,0,0,0.8)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 4 }}>
|
||||
{/* Step 0: 환영 */}
|
||||
{step === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Led color="amber" pulse size={16} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '24px',
|
||||
fontWeight: 300,
|
||||
color: d3roPalette.accent.amber,
|
||||
mt: 3,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
D3RO-VOICE
|
||||
</Typography>
|
||||
<Typography sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
||||
타이핑 없이, 음성으로. 로컬 AI 음성 어시스턴트입니다.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => setStep(1)} fullWidth>
|
||||
시작하기
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 1: 마이크 */}
|
||||
{step === 1 && (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
||||
<MicIcon sx={{ color: d3roPalette.accent.amber }} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
||||
마이크 설정
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
||||
사용할 마이크를 선택하세요. 나중에 설정에서 변경할 수 있습니다.
|
||||
</Typography>
|
||||
<Stack spacing={1} mb={3}>
|
||||
{devices.map((d) => (
|
||||
<Box
|
||||
key={d.deviceId}
|
||||
onClick={() => {
|
||||
setSelectedDevice(d.deviceId)
|
||||
window.electronAPI.audio.setSelectedDevice({ deviceId: d.deviceId })
|
||||
}}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
bgcolor: selectedDevice === d.deviceId ? d3roPalette.accent.amberDim : d3roPalette.bg.inset,
|
||||
border: selectedDevice === d.deviceId
|
||||
? `1px solid ${d3roPalette.accent.amber}`
|
||||
: `1px solid ${d3roPalette.border.subtle}`,
|
||||
'&:hover': { bgcolor: d3roPalette.bg.cardHover },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontSize: '13px' }}>
|
||||
{d.label}{d.isDefault ? ' (기본)' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Button onClick={() => setStep(0)} sx={{ color: d3roPalette.text.inactive }}>뒤로</Button>
|
||||
<Button variant="contained" onClick={() => setStep(2)}>다음</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 2: 핫키 */}
|
||||
{step === 2 && (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
||||
<KeyboardIcon sx={{ color: d3roPalette.accent.amber }} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
||||
단축키 설정
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
||||
받아쓰기 단축키를 설정하세요. 키를 누르고 있는 동안 녹음됩니다.
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '10px',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
boxShadow: 'inset 0 2px 6px rgba(0,0,0,0.4)',
|
||||
textAlign: 'center',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{hotkeyBinding ? (
|
||||
<Stack direction="row" spacing={1} justifyContent="center" alignItems="center">
|
||||
<Led color="green" size={8} />
|
||||
{hotkeyBinding.displayLabel.split(' + ').map((key) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={key}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: 700,
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
color: d3roPalette.text.primary,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '13px' }}>
|
||||
단축키가 설정되지 않았습니다
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
onClick={() => setHotkeyModalOpen(true)}
|
||||
sx={{ mb: 3, fontFamily: d3roFontMono }}
|
||||
>
|
||||
{hotkeyBinding ? '단축키 변경' : '단축키 설정'}
|
||||
</Button>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Button onClick={() => setStep(1)} sx={{ color: d3roPalette.text.inactive }}>뒤로</Button>
|
||||
<Button variant="contained" onClick={() => setStep(3)}>다음</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 3: 완료 */}
|
||||
{step === 3 && (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<CheckCircleIcon sx={{ fontSize: 48, color: d3roPalette.tag.green, mb: 2 }} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '18px', fontWeight: 700, mb: 1 }}>
|
||||
설정 완료!
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
||||
{hotkeyBinding
|
||||
? `${hotkeyBinding.displayLabel} 키를 누르고 말하면 음성이 텍스트로 변환됩니다.`
|
||||
: '설정에서 단축키를 지정하면 음성 받아쓰기를 시작할 수 있습니다.'}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={handleFinish} fullWidth>
|
||||
시작하기
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<HotkeyRecordModal
|
||||
open={hotkeyModalOpen}
|
||||
onClose={() => setHotkeyModalOpen(false)}
|
||||
onSave={handleHotkeySave}
|
||||
currentBinding={hotkeyBinding}
|
||||
title="받아쓰기 단축키 설정"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -191,6 +191,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
// 오디오 디바이스
|
||||
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('default')
|
||||
const [micTesting, setMicTesting] = useState(false)
|
||||
const [micLevel, setMicLevel] = useState(0)
|
||||
|
||||
// 설정 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -500,6 +502,58 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 마이크 테스트 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant={micTesting ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (micTesting) {
|
||||
setMicTesting(false)
|
||||
setMicLevel(0)
|
||||
} else {
|
||||
setMicTesting(true)
|
||||
// 3초 후 자동 종료
|
||||
const unsub = window.electronAPI.voice.onAudioLevel((e) => {
|
||||
setMicLevel(e.level)
|
||||
})
|
||||
setTimeout(() => {
|
||||
setMicTesting(false)
|
||||
setMicLevel(0)
|
||||
unsub()
|
||||
}, 5000)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
minWidth: 80,
|
||||
}}
|
||||
>
|
||||
{micTesting ? 'STOP' : 'TEST'}
|
||||
</Button>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
height: 8,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${Math.min(100, micLevel * 100)}%`,
|
||||
height: '100%',
|
||||
bgcolor: micLevel > 0.7 ? d3roPalette.tag.red : d3roPalette.accent.amber,
|
||||
borderRadius: '4px',
|
||||
transition: 'width 100ms ease-out',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
|
|
|
|||
119
src/renderer/components/ds/MetalDial.tsx
Normal file
119
src/renderer/components/ds/MetalDial.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// src/renderer/components/ds/MetalDial.tsx
|
||||
// 시안 A: 메탈 다이얼 — 정밀기기 회전 노브, 동심원 그루브, LED 인디케이터
|
||||
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||
|
||||
interface MetalDialProps {
|
||||
/** 0.0 ~ 1.0 값 (다이얼 위치) */
|
||||
value?: number
|
||||
/** 라벨 텍스트 */
|
||||
label?: string
|
||||
/** 크기 (px) */
|
||||
size?: number
|
||||
/** LED 인디케이터 색상 */
|
||||
ledColor?: string
|
||||
}
|
||||
|
||||
export function MetalDial({
|
||||
value = 0,
|
||||
label,
|
||||
size = 120,
|
||||
ledColor = d3roPalette.accent.amber,
|
||||
}: MetalDialProps): React.ReactElement {
|
||||
const rotation = value * 270 - 135 // -135° ~ +135° 범위
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
{/* 다이얼 외곽 */}
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
boxShadow: `
|
||||
0 4px 12px rgba(0,0,0,0.5),
|
||||
inset 0 2px 4px rgba(255,255,255,0.08),
|
||||
inset 0 -2px 4px rgba(0,0,0,0.3)
|
||||
`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* 동심원 그루브 (CSS로 시뮬레이션) */}
|
||||
<Box
|
||||
sx={{
|
||||
width: size - 16,
|
||||
height: size - 16,
|
||||
borderRadius: '50%',
|
||||
background: `
|
||||
repeating-radial-gradient(
|
||||
circle at center,
|
||||
${d3roPalette.bg.chassis} 0px,
|
||||
${d3roPalette.bg.card} 1px,
|
||||
${d3roPalette.bg.chassis} 2px
|
||||
)
|
||||
`,
|
||||
boxShadow: `
|
||||
inset 0 1px 3px rgba(0,0,0,0.6),
|
||||
0 1px 1px rgba(255,255,255,0.05)
|
||||
`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transition: 'transform 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* 포인터 인디케이터 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: '50%',
|
||||
bgcolor: ledColor,
|
||||
boxShadow: `0 0 6px ${ledColor}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* LED 인디케이터 (우측 상단) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: size * 0.25,
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: '50%',
|
||||
bgcolor: ledColor,
|
||||
boxShadow: `0 0 4px ${ledColor}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 라벨 */}
|
||||
{label && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '9px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1.5px',
|
||||
color: d3roPalette.text.inactive,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,3 +7,4 @@ export { Led } from './Led'
|
|||
export { PhysicalButton } from './PhysicalButton'
|
||||
export { MetalCard } from './MetalCard'
|
||||
export { PhosphorText } from './PhosphorText'
|
||||
export { MetalDial } from './MetalDial'
|
||||
|
|
|
|||
|
|
@ -163,12 +163,14 @@
|
|||
audioLevel = data.level || 0
|
||||
})
|
||||
|
||||
// 상태 변경
|
||||
// 상태 변경 (showRecordingTip + updateRecordingTipState 양쪽에서 사용)
|
||||
window.popupAPI.on('window:tipStateChanged', function (data) {
|
||||
var state = data.state
|
||||
if (state === 'recording') showRecording()
|
||||
else if (state === 'thinking') showThinking()
|
||||
else if (state === 'error') showError(data.errorMessage)
|
||||
// 항상 가시성 보장
|
||||
container.style.opacity = '1'
|
||||
})
|
||||
|
||||
// 클릭 시 녹음 취소
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue