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

@ -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>
)
}