From c338a19c90c8644189719e0d1e7dd10860831456 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 5 Apr 2026 12:16:39 +0900 Subject: [PATCH] =?UTF-8?q?Phase=209=20=EC=99=84=EB=A3=8C:=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=82=A8=EC=9D=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. RecordingTip: thinking 상태 전환 시 opacity 보장 2. 히스토리 오디오 경로: WAV 파일 경로를 DB에 기록 3. 마이크 테스트 UI: Settings Audio 탭에 TEST 버튼 + 레벨 바 4. 온보딩 플로우: 첫 실행 시 3단계 안내 (환영→마이크→핫키→완료) 5. MetalDial 컴포넌트: 동심원 그루브, 회전 포인터, LED 인디케이터 --- src/main/bootstrap.ts | 9 +- src/renderer/components/AppLayout.tsx | 17 +- src/renderer/components/OnboardingModal.tsx | 231 ++++++++++++++++++++ src/renderer/components/SettingsModal.tsx | 54 +++++ src/renderer/components/ds/MetalDial.tsx | 119 ++++++++++ src/renderer/components/ds/index.ts | 1 + src/renderer/popups/recording-tip/script.js | 4 +- 7 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 src/renderer/components/OnboardingModal.tsx create mode 100644 src/renderer/components/ds/MetalDial.tsx diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index 57f55da..fa3b23c 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -137,16 +137,21 @@ async function initVoiceMode(): Promise { 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)}`) diff --git a/src/renderer/components/AppLayout.tsx b/src/renderer/components/AppLayout.tsx index edda4cd..70aa298 100644 --- a/src/renderer/components/AppLayout.tsx +++ b/src/renderer/components/AppLayout.tsx @@ -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('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 + if (!cfg['onboardingCompleted']) { + setOnboardingOpen(true) + } + } + }) + }, []) return ( @@ -161,6 +175,7 @@ export function AppLayout(): React.ReactElement { setSettingsOpen(false)} /> + setOnboardingOpen(false)} /> ) } diff --git a/src/renderer/components/OnboardingModal.tsx b/src/renderer/components/OnboardingModal.tsx new file mode 100644 index 0000000..e46c7ae --- /dev/null +++ b/src/renderer/components/OnboardingModal.tsx @@ -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([]) + const [selectedDevice, setSelectedDevice] = useState('default') + const [hotkeyBinding, setHotkeyBinding] = useState(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 ( + <> + + + {/* Step 0: 환영 */} + {step === 0 && ( + + + + D3RO-VOICE + + + 타이핑 없이, 음성으로. 로컬 AI 음성 어시스턴트입니다. + + + + )} + + {/* Step 1: 마이크 */} + {step === 1 && ( + + + + + 마이크 설정 + + + + 사용할 마이크를 선택하세요. 나중에 설정에서 변경할 수 있습니다. + + + {devices.map((d) => ( + { + 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 }, + }} + > + + {d.label}{d.isDefault ? ' (기본)' : ''} + + + ))} + + + + + + + )} + + {/* Step 2: 핫키 */} + {step === 2 && ( + + + + + 단축키 설정 + + + + 받아쓰기 단축키를 설정하세요. 키를 누르고 있는 동안 녹음됩니다. + + + {hotkeyBinding ? ( + + + {hotkeyBinding.displayLabel.split(' + ').map((key) => ( + + ))} + + ) : ( + + 단축키가 설정되지 않았습니다 + + )} + + + + + + + + )} + + {/* Step 3: 완료 */} + {step === 3 && ( + + + + 설정 완료! + + + {hotkeyBinding + ? `${hotkeyBinding.displayLabel} 키를 누르고 말하면 음성이 텍스트로 변환됩니다.` + : '설정에서 단축키를 지정하면 음성 받아쓰기를 시작할 수 있습니다.'} + + + + )} + + + + setHotkeyModalOpen(false)} + onSave={handleHotkeySave} + currentBinding={hotkeyBinding} + title="받아쓰기 단축키 설정" + /> + + ) +} diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index a937d41..40f3d96 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -191,6 +191,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac // 오디오 디바이스 const [audioDevices, setAudioDevices] = useState([]) const [selectedDeviceId, setSelectedDeviceId] = useState('default') + const [micTesting, setMicTesting] = useState(false) + const [micLevel, setMicLevel] = useState(0) // 설정 로드 useEffect(() => { @@ -500,6 +502,58 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac + {/* 마이크 테스트 */} + + + + 0.7 ? d3roPalette.tag.red : d3roPalette.accent.amber, + borderRadius: '4px', + transition: 'width 100ms ease-out', + }} + /> + + + diff --git a/src/renderer/components/ds/MetalDial.tsx b/src/renderer/components/ds/MetalDial.tsx new file mode 100644 index 0000000..0fcd242 --- /dev/null +++ b/src/renderer/components/ds/MetalDial.tsx @@ -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 ( + + {/* 다이얼 외곽 */} + + {/* 동심원 그루브 (CSS로 시뮬레이션) */} + + {/* 포인터 인디케이터 */} + + + + {/* LED 인디케이터 (우측 상단) */} + + + + {/* 라벨 */} + {label && ( + + {label} + + )} + + ) +} diff --git a/src/renderer/components/ds/index.ts b/src/renderer/components/ds/index.ts index e851354..253f650 100644 --- a/src/renderer/components/ds/index.ts +++ b/src/renderer/components/ds/index.ts @@ -7,3 +7,4 @@ export { Led } from './Led' export { PhysicalButton } from './PhysicalButton' export { MetalCard } from './MetalCard' export { PhosphorText } from './PhosphorText' +export { MetalDial } from './MetalDial' diff --git a/src/renderer/popups/recording-tip/script.js b/src/renderer/popups/recording-tip/script.js index c9e776e..a7ec56d 100644 --- a/src/renderer/popups/recording-tip/script.js +++ b/src/renderer/popups/recording-tip/script.js @@ -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' }) // 클릭 시 녹음 취소