Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import * as React from "react";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의도적으로 React.useState, React.useEffect를 사용하기 위해 해당 방식으로 import하신건가요?!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 맞아요 기존에 components에 있는 컴포넌트들이 React. 구조를 사용하고 있어서 맞췄습니다.

제 개인적인 생각으로도 저희가 내부적으로 정의하는 API / hook 들이랑 React에서 제공하는 API랑 명시적으로 구분짓기 좋은 패턴이라고 생각됐어요

https://github.com/boostcampwm2025/web11-MMH/blob/develop/apps/web/src/components/button/button.tsx#L4

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하 이해했습니다!
혼자 코드 작성하던 습관이 있어서 해당 부분 놓치고 넘어갔었는데 잡고 가는 것 같아서 좋은 것 같아요!
컨벤션에 해당 부분 추가하면 다른 분들과 통일할 수 있을 것 같습니당!!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 좋습니다 제가 우선 추가해두겠습니다! 내일 스크럼 때 한번 더 이야기 해보시죠!

import Waveform from "@/components/waveform/waveform";
import { Button } from "@/components/button/button";
import { CheckCircle2, Mic, RotateCcw, Square } from "lucide-react";

function RecordingSection() {
const [isRecording, setIsRecording] = React.useState(false);
const [audioStreamingSessionId, setAudioStreamingSessionId] =
React.useState<string>();

return (
<section className="flex flex-col gap-6">
<Waveform isRecording={isRecording} />
{/* Controls */}
<div className="flex items-center justify-center gap-4">
{isRecording && (
<Button
onClick={() => {
setIsRecording(false);
setAudioStreamingSessionId("streaming-session-id");
}}
variant="destructive"
size="lg"
>
<Square className="w-4 h-4 fill-current" /> 중지
</Button>
)}
{!audioStreamingSessionId && !isRecording && (
<Button
onClick={() => {
setIsRecording(true);
}}
size="lg"
>
<Mic className="w-4 h-4" /> 녹음 시작
</Button>
)}
{audioStreamingSessionId && !isRecording && (
<div className="flex items-center gap-3 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Button
variant="outline"
onClick={() => {
setIsRecording(true);
setAudioStreamingSessionId(undefined);
}}
>
<RotateCcw className="w-4 h-4" /> 다시 시도
</Button>
<Button className="pl-6 pr-6">
답변 제출 <CheckCircle2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
</section>
);
}

export default RecordingSection;
61 changes: 61 additions & 0 deletions apps/web/src/app/questions/[questionId]/daily/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import RecordingSection from "./_components/recording-section";

interface PageProps {
params: Promise<{
questionId: string;
}>;
}

async function DailyQuestionPage({ params }: PageProps) {
const { questionId } = await params;
const activeQuestion = MOCK_CS_QUESTIONS[+questionId - 1];

return (
<div className="min-h-screen bg-zinc-50 flex flex-col text-zinc-900 selection:bg-zinc-200">
<main className="flex-1 max-w-3xl w-full mx-auto px-6 py-12 flex flex-col gap-8">
{/* Question Card */}
<section className="bg-white rounded-xl border border-zinc-200 shadow-sm p-8 transition-all duration-300 hover:shadow-md">
<div className="flex items-center justify-between mb-6">
<span
className={`px-2.5 py-1 rounded-full text-xs font-medium border
${
activeQuestion.difficulty === "Easy"
? "bg-green-50 text-green-700 border-green-200"
: activeQuestion.difficulty === "Medium"
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-red-50 text-red-700 border-red-200"
}`}
>
{activeQuestion.difficulty}
</span>
<span className="text-xs text-zinc-400 font-medium">
{activeQuestion.category}
</span>
</div>
<h2 className="text-2xl font-semibold leading-tight mb-4">
{activeQuestion.text}
</h2>
<div className="mb-4">
<p className="text-zinc-600 leading-relaxed text-[15px]">
{activeQuestion.description}
</p>
</div>
</section>
<RecordingSection />
</main>
</div>
);
}

export default DailyQuestionPage;

export const MOCK_CS_QUESTIONS = [
{
id: "1",
category: "React",
text: "React의 Virtual DOM에 대해 설명하고, 이것이 어떻게 성능을 향상시키는지 설명해주세요.",
description:
"재조정(Reconciliation)과 Diffing 알고리즘에 초점을 맞춰주세요. 실제 DOM 조작이 왜 비용이 많이 드는지, 그리고 React가 어떻게 가벼운 객체 트리를 사용하여 업데이트를 효율적으로 처리하는지 설명해주세요.",
difficulty: "Medium",
},
];
31 changes: 31 additions & 0 deletions apps/web/src/components/waveform/waveform.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite";

import Waveform from "./waveform";

const meta = {
title: "Components/Waveform",
component: Waveform,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
argTypes: {
isRecording: {
control: "boolean",
description: "녹음 상태를 제어합니다",
},
className: {
control: "text",
description: "추가 CSS 클래스",
},
},
} satisfies Meta<typeof Waveform>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
isRecording: false,
},
};
219 changes: 219 additions & 0 deletions apps/web/src/components/waveform/waveform.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"use client";

import * as React from "react";
import { cn } from "@/lib/cn";
import useAnimationFrame from "@/hooks/use-animation-frame";
import createAudioStreamer, { AudioStreamerHandle } from "@/lib/audio-streamer";

interface WaveFormProps {
isRecording: boolean;
className?: string;
}

const BAR_WIDTH = 4;
const BAR_GAP = 2;
const BAR_COLOR = "#18181b"; // Zinc 900
const RIGHT_PADDING = 12; // 오른쪽 패딩 (픽셀)
const UPDATE_INTERVAL = 30; // 50ms마다 히스토리에 추가 (1초에 20번)
const PADDING_BARS = Math.ceil(RIGHT_PADDING / (BAR_WIDTH + BAR_GAP)); // 패딩 구간의 바 개수
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분과 mock 데이터 따로 상수 파일로 분리해서 처리하는 방향은 어떠신가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀주신대로 mock 데이터를 별도 파일로 분리하는게 좋을 것 같습니다!

BAR_... 값들 같은 경우에는 Waveform 컴포넌트에서 밖에 사용안돼서 별도 파일로 분리하기가 애매하다고 생각했어요.

그냥 해당 값들을 Props에 넣어버리고, default value로 위 값들을 사용하게 하는 것도 괜찮을 것 같은데 어떠신가요? <- 처음에 이렇게 안한 이유는 커스터마이징을 열어두지 않게 하고 싶었어요. 그런데 지금 생각해보면 why not 인 것 같습니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 우선 mock도 서버 + 테스트 DB에서 관리하는게 나아보여서 지금 mock 관련해서 사용하는 코드를 지우고 하드코딩 해놓은 쪽으로 변경해두겠습니다

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀주신대로 mock 데이터를 별도 파일로 분리하는게 좋을 것 같습니다!

BAR_... 값들 같은 경우에는 Waveform 컴포넌트에서 밖에 사용안돼서 별도 파일로 분리하기가 애매하다고 생각했어요.

그냥 해당 값들을 Props에 넣어버리고, default value로 위 값들을 사용하게 하는 것도 괜찮을 것 같은데 어떠신가요? <- 처음에 이렇게 안한 이유는 커스터마이징을 열어두지 않게 하고 싶었어요. 그런데 지금 생각해보면 why not 인 것 같습니다

default value로 처리하는 것 좋은 것 같습니다!!


function Waveform({ isRecording, className }: WaveFormProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const canvasWidthRef = React.useRef<number>(0);
const canvasHeightRef = React.useRef<number>(0);
const amplitudeHistoryRef = React.useRef<number[]>([]);
const lastUpdateTimeRef = React.useRef<number>(0);
const streamerRef = React.useRef<AudioStreamerHandle | null>(null);

React.useEffect(() => {
const streamer = streamerRef.current;
if (isRecording) {
streamer?.start();
}

return () => {
streamer?.stop();
};
}, [isRecording]);

React.useEffect(() => {
streamerRef.current = createAudioStreamer({
sampleRate: 16000,
channels: 1,
bitsPerSample: 16,
onAudioChunk: ({ wave }) => {
const now = Date.now();

// 일정 간격이 지나지 않았으면 스킵
if (now - lastUpdateTimeRef.current < UPDATE_INTERVAL) {
return;
}

lastUpdateTimeRef.current = now;

// wave는 Float32Array이므로 RMS(Root Mean Square) 값을 계산
let sum = 0;
for (let i = 0; i < wave.length; i++) {
sum += wave[i] * wave[i];
}
const rms = Math.sqrt(sum / wave.length);

// RMS 값을 캔버스 높이에 맞게 스케일링
const maxAmplitude = canvasHeightRef.current * 0.6; // 캔버스 높이의 80%를 최대값으로
const amplitude = rms * maxAmplitude * 6; // RMS 값을 10배로 증폭

// 최소 높이 설정 (너무 작으면 보이지 않으므로)
const finalAmplitude = Math.max(Math.min(amplitude, maxAmplitude), 4);

amplitudeHistoryRef.current.push(finalAmplitude);

// 히스토리가 너무 길어지지 않도록 제한
const maxBars =
Math.ceil(canvasWidthRef.current / (BAR_WIDTH + BAR_GAP)) + 10;
if (amplitudeHistoryRef.current.length > maxBars) {
amplitudeHistoryRef.current.shift();
}
},
});
}, []);

React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}

const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}

const resizeCanvas = () => {
const devicePixelRatio = window.devicePixelRatio || 1;
canvasWidthRef.current = canvas.clientWidth;
canvasHeightRef.current = canvas.clientHeight;

const nextCanvasWidth = Math.max(
1,
Math.round(canvasWidthRef.current * devicePixelRatio)
);
const nextCanvasHeight = Math.max(
1,
Math.round(canvasHeightRef.current * devicePixelRatio)
);

if (canvas.width !== nextCanvasWidth) {
canvas.width = nextCanvasWidth;
}

if (canvas.height !== nextCanvasHeight) {
canvas.height = nextCanvasHeight;
}

ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
};

resizeCanvas();

const resizeObserver = new ResizeObserver(() => {
resizeCanvas();
});

resizeObserver.observe(canvas);

return () => {
resizeObserver.disconnect();
};
}, []);

useAnimationFrame(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}

const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}

// 캔버스 클리어
ctx.clearRect(0, 0, canvasWidthRef.current, canvasHeightRef.current);

const history = amplitudeHistoryRef.current;

// 패딩 구간의 IDLE 바 그리기 (오른쪽 끝)
if (history.length) {
for (let i = 0; i < PADDING_BARS; i++) {
const barX =
canvasWidthRef.current - i * (BAR_WIDTH + BAR_GAP) - BAR_WIDTH;
const idleAmplitude = 4; // IDLE 바의 작은 높이
const barY = canvasHeightRef.current / 2 - idleAmplitude / 2;

ctx.fillStyle = BAR_COLOR;
ctx.beginPath();
const radius = BAR_WIDTH / 2;
ctx.roundRect(barX, barY, BAR_WIDTH, idleAmplitude, radius);
ctx.fill();
}
}

// 히스토리의 각 amplitude를 그리기 (패딩 이후부터)
for (let i = 0; i < history.length; i++) {
const indexFromRight = history.length - 1 - i;
const amplitude = history[indexFromRight];

// 패딩 구간을 고려한 위치 계산
const barX =
canvasWidthRef.current -
(PADDING_BARS + i) * (BAR_WIDTH + BAR_GAP) -
BAR_WIDTH;
const barY = canvasHeightRef.current / 2 - amplitude / 2;

// 바가 화면 밖으로 나가면 그리지 않음
if (barX + BAR_WIDTH < 0) {
break;
}

ctx.fillStyle = BAR_COLOR;
ctx.beginPath();
const radius = BAR_WIDTH / 2;
ctx.roundRect(barX, barY, BAR_WIDTH, amplitude, radius);
ctx.fill();
}
});

return (
<div
className={cn(
"w-full h-40 bg-white rounded-xl border border-zinc-200 flex items-center justify-center overflow-hidden relative shadow-sm group",
className
)}
>
{/* Background Grid Pattern */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage:
"linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
/>
<canvas ref={canvasRef} className="w-full h-full relative z-10" />
{!isRecording ? (
<div className="absolute bottom-3 right-4 text-zinc-400 text-xs font-mono font-medium select-none pointer-events-none bg-white/80 px-2 py-1 rounded backdrop-blur-sm border border-zinc-100">
READY TO RECORD
</div>
) : (
<div className="absolute top-3 right-3 flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-xs font-medium text-red-500 uppercase tracking-wider">
Recording
</span>
</div>
)}
</div>
);
}

export default Waveform;
Loading
Loading