feat(desktop): Voice Conversation 몰입 UX 패널 + 사운드 피드백 + Bug 13 빈 STT (빅뱅 Phase 5 Part 6)
listening 상태에서 풀 몰입 계측기 모드로 전환되는 VoiceRecordingPanel 추가.
recording-tip 팝업의 9바 cos-분포 waveform(BAR_COUNT=9, SMOOTHING=0.5,
RANDOM_FACTOR=0.35, 100ms)을 React로 포팅해 REC LED + elapsed 타이머 +
"SPEAK NOW" 힌트까지 구성. thinking/speaking 상태에서는 메시지 리스트로
복귀해 대화 맥락 유지 + 점 3개 typing indicator 버블 추가.
VoiceConversationService에 AudioCaptureService audio-level forwarding과
사운드 훅 4개(recording-start / recording-stop / chime / error)를 삽입.
chime은 recording-stop.wav 재사용(SoundEffectService SoundName 확장).
VOICE_CONVERSATION.AUDIO_LEVEL 채널 신설 + preload onAudioLevel API.
U8 Bug 13 동반 해소: finishListening에서 minBytes 미달 또는 VAD 무음 판정으로
빈 텍스트가 나오는 경우 조용히 listening으로 복귀하던 것을 _emitError('stt')로
사용자 피드백(에러 사운드 + 에러 이벤트)을 노출하도록 수정. 사용자가 "⏹ 눌러도
반응 없음"으로 오해하던 증상 해소.
This commit is contained in:
parent
e420e35ade
commit
412a2e71f9
9 changed files with 385 additions and 94 deletions
|
|
@ -0,0 +1,164 @@
|
|||
// src/renderer/components/voice-conversation/VoiceRecordingPanel.tsx
|
||||
// Phase 5 Part 6: Voice Conversation 몰입 녹음 패널
|
||||
// recording-tip 팝업의 9바 waveform (cos 분포, 100ms, smoothing)을 React로 포팅.
|
||||
// 기존 구현: src/renderer/popups/recording-tip/script.js (Vanilla JS, 팝업 전용)
|
||||
// 스펙: docs/design/03-db-and-ui.md:428+
|
||||
//
|
||||
// AudioLevelSource:
|
||||
// - VoiceConversationService가 listening 상태에서만 AUDIO_LEVEL 채널로 forwarding.
|
||||
// - level 값은 0.0~1.0. 구독자가 없는 thinking/speaking에는 Panel 자체가 언마운트되므로
|
||||
// useEffect cleanup으로 subscription이 자동 해제됨.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { Led, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
||||
// ── Waveform 상수 (recording-tip/script.js 수치 그대로) ──
|
||||
const BAR_COUNT = 9
|
||||
const UPDATE_INTERVAL = 100
|
||||
const MIN_HEIGHT = 2
|
||||
const MAX_HEIGHT = 28
|
||||
const SMOOTHING = 0.5
|
||||
const RANDOM_FACTOR = 0.35
|
||||
|
||||
/** 코사인 분포 가중치 (중앙이 가장 높음). 설계서 03. */
|
||||
const weights: readonly number[] = Array.from({ length: BAR_COUNT }, (_, i) => {
|
||||
const center = (BAR_COUNT - 1) / 2
|
||||
const normalized = (i - center) / center
|
||||
return Math.cos((normalized * Math.PI) / 2)
|
||||
})
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const elapsed = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(elapsed / 60)
|
||||
const seconds = elapsed % 60
|
||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice Conversation이 listening 상태일 때만 렌더되는 몰입 녹음 패널.
|
||||
* - 9바 phosphor waveform (audio-level 기반)
|
||||
* - REC LED + elapsed 타이머
|
||||
* - "SPEAK NOW · PRESS ⏹ TO SEND" 힌트 (i18n)
|
||||
*/
|
||||
export function VoiceRecordingPanel(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [heights, setHeights] = useState<number[]>(() =>
|
||||
new Array(BAR_COUNT).fill(MIN_HEIGHT),
|
||||
)
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
|
||||
const heightsRef = useRef<number[]>(new Array(BAR_COUNT).fill(MIN_HEIGHT))
|
||||
const audioLevelRef = useRef(0)
|
||||
const startedAtRef = useRef<number>(Date.now())
|
||||
|
||||
// audio-level 구독 (VoiceConversationService → AUDIO_LEVEL)
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electronAPI.voiceConversation.onAudioLevel((e) => {
|
||||
audioLevelRef.current = e.level
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
// 9바 애니메이션 루프 (100ms interval)
|
||||
useEffect(() => {
|
||||
startedAtRef.current = Date.now()
|
||||
heightsRef.current = new Array(BAR_COUNT).fill(MIN_HEIGHT)
|
||||
audioLevelRef.current = 0
|
||||
|
||||
const animInterval = window.setInterval(() => {
|
||||
const level = audioLevelRef.current
|
||||
// 최소 진동: audioLevel이 0이어도 바가 미세하게 움직여 "살아있음" 표현
|
||||
const effectiveLevel = Math.max(0.08, level)
|
||||
const next = new Array(BAR_COUNT).fill(MIN_HEIGHT) as number[]
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const baseTarget = effectiveLevel * 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))
|
||||
// 스무딩 보간
|
||||
const prev = heightsRef.current[i]
|
||||
const smoothed = prev + (target - prev) * SMOOTHING
|
||||
heightsRef.current[i] = smoothed
|
||||
next[i] = Math.round(smoothed)
|
||||
}
|
||||
setHeights(next)
|
||||
}, UPDATE_INTERVAL)
|
||||
|
||||
const durationInterval = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAtRef.current)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(animInterval)
|
||||
window.clearInterval(durationInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 3,
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
{/* REC LED + 타이머 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Led color="red" pulse size={10} />
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.accent.amber }}>
|
||||
{t('conversation.state.recording')}
|
||||
</PhosphorText>
|
||||
<Box
|
||||
sx={{
|
||||
ml: 1,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 16,
|
||||
color: d3roPalette.accent.amber,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
minWidth: 40,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{formatDuration(elapsedMs)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 9바 waveform */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
height: `${MAX_HEIGHT + 4}px`,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{heights.map((h, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: '4px',
|
||||
height: `${h}px`,
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
borderRadius: '2px',
|
||||
transition: 'height 100ms ease-out',
|
||||
boxShadow: `0 0 6px ${d3roPalette.accent.amberGlow}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 힌트 */}
|
||||
<PhosphorText variant="dim" sx={{ letterSpacing: '0.08em' }}>
|
||||
{`─ ${t('conversation.recording.hint')} ─`}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
|
|||
import CancelIcon from '@mui/icons-material/Cancel'
|
||||
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '@d3ro/ui/components/ds'
|
||||
import { PageHeader } from '../components/shared'
|
||||
import { VoiceRecordingPanel } from '../components/voice-conversation/VoiceRecordingPanel'
|
||||
import { isImeComposingEvent } from '../utils/keyboard'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
|
@ -154,75 +155,121 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
}
|
||||
/>
|
||||
|
||||
{/* 메시지 목록 */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
mt: 2,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 && !streamingText && (
|
||||
<Box sx={{ textAlign: 'center', mt: 8 }}>
|
||||
<PhosphorText variant="heading" sx={{ color: d3roPalette.text.inactive, mb: 1 }}>
|
||||
{t('conversation.empty')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim">
|
||||
{t('conversation.emptyHint')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
{/* 메시지 영역 — state 분기: listening=몰입 패널, 그 외=메시지 리스트 */}
|
||||
{state === 'listening' ? (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
mt: 2,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<VoiceRecordingPanel />
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
mt: 2,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 && !streamingText && state !== 'thinking' && (
|
||||
<Box sx={{ textAlign: 'center', mt: 8 }}>
|
||||
<PhosphorText variant="heading" sx={{ color: d3roPalette.text.inactive, mb: 1 }}>
|
||||
{t('conversation.empty')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim">
|
||||
{t('conversation.emptyHint')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<MetalCard
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
maxWidth: '75%',
|
||||
...(msg.role === 'user' && {
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
'& *': { color: `${d3roPalette.bg.chassis} !important` },
|
||||
}),
|
||||
display: 'flex',
|
||||
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<PhosphorText
|
||||
variant="compact"
|
||||
<MetalCard
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.6,
|
||||
maxWidth: '75%',
|
||||
...(msg.role === 'user' && {
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
'& *': { color: `${d3roPalette.bg.chassis} !important` },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
))}
|
||||
<PhosphorText
|
||||
variant="compact"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* 스트리밍 중인 어시스턴트 메시지 */}
|
||||
{streamingText && streamingMsgId && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
<MetalCard sx={{ maxWidth: '75%' }}>
|
||||
<PhosphorText variant="compact" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{streamingText}
|
||||
<Box component="span" sx={{ animation: 'blink 1s infinite', color: d3roPalette.accent.amber }}>
|
||||
{'▌'}
|
||||
{/* thinking 중: typing indicator (아직 스트리밍 시작 전) */}
|
||||
{state === 'thinking' && !streamingText && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
<MetalCard sx={{ maxWidth: '75%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
'& > span': {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
animation: 'd3roTypingBounce 1.2s infinite ease-in-out',
|
||||
},
|
||||
'& > span:nth-of-type(2)': { animationDelay: '0.15s' },
|
||||
'& > span:nth-of-type(3)': { animationDelay: '0.3s' },
|
||||
'@keyframes d3roTypingBounce': {
|
||||
'0%, 80%, 100%': { opacity: 0.3, transform: 'translateY(0)' },
|
||||
'40%': { opacity: 1, transform: 'translateY(-3px)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box component="span" />
|
||||
<Box component="span" />
|
||||
<Box component="span" />
|
||||
</Box>
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)}
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
{/* 스트리밍 중인 어시스턴트 메시지 (thinking 말미 or speaking) */}
|
||||
{streamingText && streamingMsgId && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
<MetalCard sx={{ maxWidth: '75%' }}>
|
||||
<PhosphorText variant="compact" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{streamingText}
|
||||
<Box component="span" sx={{ animation: 'blink 1s infinite', color: d3roPalette.accent.amber }}>
|
||||
{'▌'}
|
||||
</Box>
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 하단 컨트롤 바 */}
|
||||
<MetalCard>
|
||||
|
|
@ -246,39 +293,50 @@ export function VoiceConversationPage(): React.ReactElement {
|
|||
</PhysicalButton>
|
||||
)}
|
||||
|
||||
{/* 텍스트 입력 */}
|
||||
<TextField
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
onKeyDown={handleTextKeyDown}
|
||||
placeholder={t('conversation.inputPlaceholder')}
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
'& fieldset': { borderColor: d3roPalette.border.subtle },
|
||||
'&:hover fieldset': { borderColor: d3roPalette.accent.amber },
|
||||
'&.Mui-focused fieldset': { borderColor: d3roPalette.accent.amber },
|
||||
},
|
||||
'& .MuiOutlinedInput-input': {
|
||||
color: d3roPalette.text.primary,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{/* 텍스트 입력 — listening 시 숨김, thinking/speaking 시 disabled */}
|
||||
{state !== 'listening' && (
|
||||
<TextField
|
||||
value={state === 'thinking' || state === 'speaking' ? '' : textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
onKeyDown={handleTextKeyDown}
|
||||
placeholder={
|
||||
state === 'thinking' || state === 'speaking'
|
||||
? t('conversation.thinking.placeholder')
|
||||
: t('conversation.inputPlaceholder')
|
||||
}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={state === 'thinking' || state === 'speaking'}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
'& fieldset': { borderColor: d3roPalette.border.subtle },
|
||||
'&:hover fieldset': { borderColor: d3roPalette.accent.amber },
|
||||
'&.Mui-focused fieldset': { borderColor: d3roPalette.accent.amber },
|
||||
},
|
||||
'& .MuiOutlinedInput-input': {
|
||||
color: d3roPalette.text.primary,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 전송 버튼 */}
|
||||
<IconButton
|
||||
onClick={handleSendText}
|
||||
disabled={!textInput.trim()}
|
||||
sx={{
|
||||
color: textInput.trim() ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
}}
|
||||
>
|
||||
<SendIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
{/* 전송 버튼 — listening 시 숨김 */}
|
||||
{state !== 'listening' && (
|
||||
<IconButton
|
||||
onClick={handleSendText}
|
||||
disabled={!textInput.trim() || state === 'thinking' || state === 'speaking'}
|
||||
sx={{
|
||||
color: textInput.trim() && state !== 'thinking' && state !== 'speaking'
|
||||
? d3roPalette.accent.amber
|
||||
: d3roPalette.text.inactive,
|
||||
}}
|
||||
>
|
||||
<SendIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* 세션 종료 */}
|
||||
{isActive && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue