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:
윤찬 2026-04-11 23:16:34 +09:00
parent e420e35ade
commit 412a2e71f9
9 changed files with 385 additions and 94 deletions

View file

@ -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 && (