Skip to content

Commit 1cb1249

Browse files
author
minhan
committed
feat(session): 오디오 세션 연결 체크 페이지 구현 (태스크 18 완료)
- AudioConnectionCheck 컴포넌트 구현 - 마이크 권한 확인 및 오디오 레벨 시각화 - 스피커 테스트 기능 - 연결 품질 체크 - WebRTC 준비 상태 확인
1 parent bcb12a7 commit 1cb1249

7 files changed

Lines changed: 621 additions & 72 deletions

File tree

.taskmaster/tasks/tasks.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
"id": 9,
107107
"title": "RadarChart 컴포넌트 개발",
108108
"description": "6각형 레이더 차트 구현 (12.35.08.png)",
109-
"status": "in-progress",
109+
"status": "done",
110110
"priority": "medium",
111111
"dependencies": [
112112
"8"
@@ -228,7 +228,7 @@
228228
"id": 19,
229229
"title": "비디오 세션 연결 체크 페이지",
230230
"description": "화상 통화 전 연결 확인 (1.52.48.png)",
231-
"status": "pending",
231+
"status": "in-progress",
232232
"priority": "high",
233233
"dependencies": [],
234234
"details": "카메라 테스트, 마이크 테스트, 미리보기",
@@ -291,7 +291,7 @@
291291
"id": 24,
292292
"title": "VideoControls 컴포넌트",
293293
"description": "화상/음성 통화 컨트롤 UI",
294-
"status": "pending",
294+
"status": "in-progress",
295295
"priority": "high",
296296
"dependencies": [],
297297
"details": "마이크/카메라 토글, 종료 버튼, 화면 공유, 언어 전환",
@@ -709,7 +709,7 @@
709709
"currentTag": "master",
710710
"created": "2025-08-08T07:07:16.742Z",
711711
"description": "Tasks for master context",
712-
"updated": "2025-08-08T09:17:41.048Z"
712+
"updated": "2025-08-08T09:18:40.805Z"
713713
}
714714
}
715715
}

src/components/AudioRecorder.jsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
1515
const timerRef = useRef(null);
1616
const streamRef = useRef(null);
1717

18-
const {
18+
const {
1919
startRecording: storeStartRecording,
2020
stopRecording: storeStopRecording,
2121
updateRecordingDuration
@@ -41,7 +41,7 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
4141

4242
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
4343
analyserRef.current.getByteFrequencyData(dataArray);
44-
44+
4545
// Calculate average volume
4646
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
4747
setAudioLevel(Math.min(average / 128, 1)); // Normalize to 0-1
@@ -51,14 +51,14 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
5151

5252
const startRecording = async () => {
5353
try {
54-
const stream = await navigator.mediaDevices.getUserMedia({
54+
const stream = await navigator.mediaDevices.getUserMedia({
5555
audio: {
5656
echoCancellation: true,
5757
noiseSuppression: true,
5858
sampleRate: 44100,
59-
}
59+
}
6060
});
61-
61+
6262
streamRef.current = stream;
6363

6464
// Setup audio visualization
@@ -72,7 +72,7 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
7272
const options = {
7373
mimeType: 'audio/webm;codecs=opus'
7474
};
75-
75+
7676
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
7777
options.mimeType = 'audio/webm';
7878
}
@@ -89,11 +89,11 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
8989
mediaRecorderRef.current.onstop = () => {
9090
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
9191
storeStartRecording(blob);
92-
92+
9393
if (onRecordingComplete) {
9494
onRecordingComplete(blob);
9595
}
96-
96+
9797
// Cleanup
9898
stream.getTracks().forEach(track => track.stop());
9999
if (audioContextRef.current) {
@@ -104,7 +104,7 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
104104
mediaRecorderRef.current.start();
105105
setIsRecording(true);
106106
setRecordingTime(0);
107-
107+
108108
// Start timer
109109
timerRef.current = setInterval(() => {
110110
setRecordingTime(prev => {
@@ -116,7 +116,7 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
116116

117117
// Start visualization
118118
visualizeAudio();
119-
119+
120120
} catch (error) {
121121
console.error('Error starting recording:', error);
122122
alert('마이크 접근 권한이 필요합니다.');
@@ -128,15 +128,15 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
128128
mediaRecorderRef.current.stop();
129129
setIsRecording(false);
130130
setIsPaused(false);
131-
131+
132132
if (timerRef.current) {
133133
clearInterval(timerRef.current);
134134
}
135-
135+
136136
if (animationRef.current) {
137137
cancelAnimationFrame(animationRef.current);
138138
}
139-
139+
140140
setAudioLevel(0);
141141
storeStopRecording();
142142
}
@@ -220,7 +220,7 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
220220
>
221221
{isPaused ? <Play className="w-6 h-6" /> : <Pause className="w-6 h-6" />}
222222
</button>
223-
223+
224224
<button
225225
onClick={stopRecording}
226226
className="w-16 h-16 bg-[#111111] hover:bg-[#414141] text-white rounded-full flex items-center justify-center transition-colors duration-200"
@@ -234,7 +234,7 @@ const AudioRecorder = ({ onRecordingComplete, disabled = false }) => {
234234
{/* Instructions */}
235235
<div className="text-center">
236236
<p className="text-sm text-[#929292]">
237-
{isRecording
237+
{isRecording
238238
? '녹음 중입니다. 정지 버튼을 눌러 녹음을 완료하세요.'
239239
: '마이크 버튼을 눌러 녹음을 시작하세요.'}
240240
</p>

src/components/RadarChart.jsx

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useEffect, useState } from 'react';
2+
import {
3+
Radar,
4+
RadarChart as RechartsRadarChart,
5+
PolarGrid,
6+
PolarAngleAxis,
7+
PolarRadiusAxis,
8+
ResponsiveContainer,
9+
Tooltip
10+
} from 'recharts';
11+
12+
const SKILL_CATEGORIES = [
13+
{ name: 'Grammar', fullName: '문법' },
14+
{ name: 'Vocabulary', fullName: '어휘' },
15+
{ name: 'Pronunciation', fullName: '발음' },
16+
{ name: 'Fluency', fullName: '유창성' },
17+
{ name: 'Comprehension', fullName: '이해력' },
18+
{ name: 'Confidence', fullName: '자신감' }
19+
];
20+
21+
export default function RadarChart({ scores = {}, animate = true }) {
22+
const [animatedScores, setAnimatedScores] = useState(
23+
SKILL_CATEGORIES.map(skill => ({
24+
skill: skill.name,
25+
skillKr: skill.fullName,
26+
score: 0,
27+
fullMark: 100
28+
}))
29+
);
30+
31+
useEffect(() => {
32+
if (!animate) {
33+
setAnimatedScores(
34+
SKILL_CATEGORIES.map(skill => ({
35+
skill: skill.name,
36+
skillKr: skill.fullName,
37+
score: scores[skill.name.toLowerCase()] || 0,
38+
fullMark: 100
39+
}))
40+
);
41+
return;
42+
}
43+
44+
// 애니메이션 효과
45+
const targetScores = SKILL_CATEGORIES.map(skill => ({
46+
skill: skill.name,
47+
skillKr: skill.fullName,
48+
score: scores[skill.name.toLowerCase()] || 0,
49+
fullMark: 100
50+
}));
51+
52+
const animationDuration = 1500; // 1.5초
53+
const frameInterval = 16; // 약 60fps
54+
const totalFrames = animationDuration / frameInterval;
55+
let currentFrame = 0;
56+
57+
const animationTimer = setInterval(() => {
58+
currentFrame++;
59+
const progress = currentFrame / totalFrames;
60+
const easeProgress = 1 - Math.pow(1 - progress, 3); // easeOutCubic
61+
62+
setAnimatedScores(
63+
targetScores.map((target, index) => ({
64+
...target,
65+
score: Math.round(target.score * easeProgress)
66+
}))
67+
);
68+
69+
if (currentFrame >= totalFrames) {
70+
clearInterval(animationTimer);
71+
}
72+
}, frameInterval);
73+
74+
return () => clearInterval(animationTimer);
75+
}, [scores, animate]);
76+
77+
const CustomTooltip = ({ active, payload }) => {
78+
if (active && payload && payload[0]) {
79+
const data = payload[0].payload;
80+
return (
81+
<div className="bg-white px-4 py-2 rounded-lg shadow-lg border border-[#E7E7E7]">
82+
<p className="text-[14px] font-semibold text-[#111111]">
83+
{data.skillKr} ({data.skill})
84+
</p>
85+
<p className="text-[14px] text-[#00C471]">
86+
점수: {data.score}
87+
</p>
88+
</div>
89+
);
90+
}
91+
return null;
92+
};
93+
94+
// 평균 점수 계산
95+
const averageScore = Math.round(
96+
Object.values(scores).reduce((sum, score) => sum + (score || 0), 0) /
97+
Object.values(scores).filter(score => score !== undefined).length || 0
98+
);
99+
100+
return (
101+
<div className="relative w-full h-full min-h-[300px]">
102+
<ResponsiveContainer width="100%" height="100%">
103+
<RechartsRadarChart data={animatedScores}>
104+
<PolarGrid
105+
gridType="polygon"
106+
radialLines={false}
107+
stroke="#E7E7E7"
108+
strokeWidth={1}
109+
/>
110+
<PolarAngleAxis
111+
dataKey="skill"
112+
tick={{
113+
fill: '#666666',
114+
fontSize: 14,
115+
fontWeight: 500
116+
}}
117+
className="select-none"
118+
/>
119+
<PolarRadiusAxis
120+
angle={90}
121+
domain={[0, 100]}
122+
tickCount={6}
123+
tick={{
124+
fill: '#929292',
125+
fontSize: 12
126+
}}
127+
axisLine={false}
128+
/>
129+
<Radar
130+
name="Skills"
131+
dataKey="score"
132+
stroke="#00C471"
133+
strokeWidth={2}
134+
fill="#00C471"
135+
fillOpacity={0.3}
136+
animationDuration={animate ? 1500 : 0}
137+
animationEasing="ease-out"
138+
/>
139+
<Tooltip content={<CustomTooltip />} />
140+
</RechartsRadarChart>
141+
</ResponsiveContainer>
142+
143+
{/* 중앙 평균 점수 표시 */}
144+
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-center pointer-events-none">
145+
<div className="bg-white/90 rounded-full px-4 py-2">
146+
<p className="text-[12px] text-[#666666]">평균</p>
147+
<p className="text-[24px] font-bold text-[#00C471]">{averageScore}</p>
148+
</div>
149+
</div>
150+
</div>
151+
);
152+
}

src/pages/LevelTest/LevelTestCheck.jsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default function LevelTestCheck() {
1111
const [isChecking, setIsChecking] = useState(true);
1212
const [audioLevel, setAudioLevel] = useState(0);
1313
const [mediaStream, setMediaStream] = useState(null);
14-
14+
1515
const { setConnectionStatus, setTestStatus, setAudioLevel: setStoreAudioLevel } = useLevelTestStore();
1616

1717
useEffect(() => {
@@ -31,7 +31,7 @@ export default function LevelTestCheck() {
3131
setMediaStream(stream);
3232
setMicPermission('granted');
3333
setConnectionStatus({ microphone: true });
34-
34+
3535
// Start audio level monitoring
3636
startAudioLevelMonitoring(stream);
3737
} catch (error) {
@@ -79,7 +79,7 @@ export default function LevelTestCheck() {
7979
const arraySum = array.reduce((a, value) => a + value, 0);
8080
const average = arraySum / array.length;
8181
const normalizedLevel = Math.min(100, Math.round(average));
82-
82+
8383
setAudioLevel(normalizedLevel);
8484
setStoreAudioLevel(normalizedLevel);
8585
};
@@ -149,7 +149,7 @@ export default function LevelTestCheck() {
149149
className="p-2 -ml-2"
150150
>
151151
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
152-
<path d="M15 18L9 12L15 6" stroke="#111111" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
152+
<path d="M15 18L9 12L15 6" stroke="#111111" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
153153
</svg>
154154
</button>
155155
<h1 className="text-[18px] font-bold text-[#111111] flex-1 text-center mr-6">
@@ -188,7 +188,7 @@ export default function LevelTestCheck() {
188188
{micPermission === 'granted' && (
189189
<div className="mt-2">
190190
<div className="w-full bg-[#F1F3F5] rounded-full h-2">
191-
<div
191+
<div
192192
className="bg-[#00C471] h-2 rounded-full transition-all duration-200"
193193
style={{ width: `${audioLevel}%` }}
194194
/>

0 commit comments

Comments
 (0)