허깅 페이스(Hugging Face) - 음성 비서 앱 (7)

2026. 6. 5. 22:58AI/LLM

멀티 모달을 활용하는 개발 경험에서 음성 비서 앱도 추가할 수 있다.

 

특히 다음과 같은 기능을 제공할 수 있다.

음성을 텍스트로 변환하고, 그것을 기준으로 질문 처리하여, 그것을 또 음성으로 변환하는 기능을 수행한다.

 

구체적인 단계는 다음과 같다.

 

먼저 화면 구성 먼저 진행해보자.

 

1단계 : 화면 구성

 

사용된 Next.js는 다음과 같다.

 

app/sound_assistant/page.tsx

import AudioComponent from "@/components/AudioComponent";
import AudioResponse from "@/components/AudioResponse";
import CommonPut from "@/components/CommonPut";

export default function SoundAssistantPage() {
    return (
        <main>
            <div id="wrap" className="w-[1080px] mx-auto">
                <h2 className="mt-[30px] mb-[20px]">AI 음성 비서</h2>
                <div id="gra__block" className="grid grid-cols-2 gap-[10px]">
                    <AudioComponent />
                    <CommonPut put={"output"} label={"텍스트 변환"} height={110} highHeight={150} />
                    <CommonPut put={"input"} label={"question"} height={40} hasButton={"질문하기"} />
                    <CommonPut put={"input"} label={"answer"} height={40} hasButton={"답변하기"} />
                </div>
                <div className="mt-[10px]">
                    <AudioResponse />
                </div>
            </div>
        </main>
    )
}

 

src/components/AudioComponent.tsx

'use client';

import { useState, useRef } from "react";
import { MdSpatialAudioOff, MdUpload } from "react-icons/md";
import { HiOutlineMicrophone } from "react-icons/hi2";
import { IoCloseCircle } from "react-icons/io5";
import { MdPlayArrow, MdPause } from "react-icons/md";
import { MdSkipPrevious, MdSkipNext } from "react-icons/md";

export default function AudioComponent() {
    const [activeTab, setActiveTab] = useState<'upload' | 'record'>('upload');
    const [uploadedAudio, setUploadedAudio] = useState<string>("");
    const [isUploading, setIsUploading] = useState(false);
    const [isPlaying, setIsPlaying] = useState(false);
    const [duration, setDuration] = useState(0);
    const [currentTime, setCurrentTime] = useState(0);
    const inputRef = useRef<HTMLInputElement | null>(null);
    const audioRef = useRef<HTMLAudioElement | null>(null);

    const handleAudioUpload = () => {
        inputRef.current?.click();
    };

    const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        if (!file) return;

        try {
            setIsUploading(true);
            const formData = new FormData();
            formData.append("file", file);
            const response = await fetch("http://localhost:8000/api/v1/file-upload", {
                method: "POST",
                body: formData,
            });
            const data = await response.json();
            setUploadedAudio(data.url);
        } catch (error) {
            console.error("Upload failed:", error);
        } finally {
            setIsUploading(false);
        }
    };

    const clearAudio = () => {
        setUploadedAudio("");
        setIsPlaying(false);
        setCurrentTime(0);
        if (inputRef.current) inputRef.current.value = "";
    };

    const handlePlayPause = () => {
        if (audioRef.current) {
            if (isPlaying) {
                audioRef.current.pause();
            } else {
                audioRef.current.play();
            }
            setIsPlaying(!isPlaying);
        }
    };

    const handleTimeUpdate = () => {
        if (audioRef.current) {
            setCurrentTime(audioRef.current.currentTime);
        }
    };

    const handleLoadedMetadata = () => {
        if (audioRef.current) {
            setDuration(audioRef.current.duration);
        }
    };

    const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const newTime = parseFloat(e.target.value);
        setCurrentTime(newTime);
        if (audioRef.current) {
            audioRef.current.currentTime = newTime;
        }
    };

    const formatTime = (time: number) => {
        if (isNaN(time)) return "0:00";
        const minutes = Math.floor(time / 60);
        const seconds = Math.floor(time % 60);
        return `${minutes}:${seconds.toString().padStart(2, "0")}`;
    };

    return (
        <div id="audioWrap">
            <div id="audioWrapBox" className="w-full h-[250px] border border-[#e6e6e8] rounded-[5px] flex flex-col">
                <div id="audioWrapBoxTitle" className="w-[80px] h-[30px] border-r border-b border-[#e6e6e8] flex items-center justify-center gap-[5px]">
                    <MdSpatialAudioOff />
                    <span className="text-[12px]">오디오</span>
                </div>
                <div id="audioWrapBoxContent" className="flex-1 flex flex-col justify-center items-center gap-[12px]">
                    {activeTab === 'upload' ? (
                        <>
                            {uploadedAudio ? (
                                <div className="w-[280px] flex flex-col items-center gap-[12px]">
                                    {/* 음파 시각화 */}
                                    <div className="w-full h-[40px] flex items-center justify-center gap-[2px] mb-[4px]">
                                        {[...Array(40)].map((_, i) => (
                                            <div
                                                key={i}
                                                className="w-[1.5px] bg-[#999] rounded-full"
                                                style={{
                                                    height: `${20 + Math.sin(i * 0.5 + currentTime) * 10}px`,
                                                    opacity: i / 40 <= (currentTime / duration) ? 1 : 0.3,
                                                }}
                                            />
                                        ))}
                                    </div>

                                    {/* 플레이어 컨트롤 */}
                                    <div className="w-full flex items-center justify-center gap-[8px] px-[8px]">
                                        <button className="text-[#999] hover:text-[#333] transition-colors">
                                            <MdSkipPrevious size={16} />
                                        </button>
                                        <button
                                            onClick={handlePlayPause}
                                            className="text-[#999] hover:text-[#333] transition-colors"
                                        >
                                            {isPlaying ? <MdPause size={16} /> : <MdPlayArrow size={16} />}
                                        </button>
                                        <button className="text-[#999] hover:text-[#333] transition-colors">
                                            <MdSkipNext size={16} />
                                        </button>
                                        <span className="text-[10px] text-[#999] min-w-[24px]">{formatTime(currentTime)}</span>
                                    </div>

                                    {/* 진행률 바 */}
                                    <input
                                        type="range"
                                        min="0"
                                        max={duration || 0}
                                        value={currentTime}
                                        onChange={handleProgressChange}
                                        className="w-full h-[2px] bg-[#ddd] rounded-full appearance-none cursor-pointer"
                                        style={{
                                            background: `linear-gradient(to right, #999 0%, #999 ${(currentTime / duration) * 100}%, #ddd ${(currentTime / duration) * 100}%, #ddd 100%)`
                                        }}
                                    />

                                    <button
                                        onClick={clearAudio}
                                        className="flex items-center gap-[6px] text-[#919096] hover:text-[#333] transition-colors text-[12px]"
                                    >
                                        <IoCloseCircle size={16} />
                                        <span>제거</span>
                                    </button>

                                    <audio
                                        ref={audioRef}
                                        onTimeUpdate={handleTimeUpdate}
                                        onLoadedMetadata={handleLoadedMetadata}
                                        onEnded={() => setIsPlaying(false)}
                                    >
                                        <source src={uploadedAudio} type="audio/mpeg" />
                                    </audio>
                                </div>
                            ) : (
                                <button
                                    onClick={handleAudioUpload}
                                    disabled={isUploading}
                                    className="flex flex-col justify-center items-center gap-[5px]"
                                >
                                    <MdUpload size={22} color={"#919096"} />
                                    <span className="text-[#d6d9d6]">오디오를 여기에 드롭</span>
                                    <span className="text-[#919096]">-또는-</span>
                                    <span className="text-[#919096]">{isUploading ? "업로드 중..." : "클릭하여 업로드"}</span>
                                </button>
                            )}
                            <input
                                type="file"
                                ref={inputRef}
                                onChange={handleFileChange}
                                accept="audio/mp3,audio/wav,audio/m4a,audio/ogg,audio/flac,.mp3,.wav,.m4a,.ogg,.flac"
                                className="hidden"
                            />
                        </>
                    ) : (
                        <button className="flex flex-col justify-center items-center gap-[5px]">
                            <HiOutlineMicrophone size={22} color={"#919096"} />
                            <span className="text-[#d6d9d6]">녹음 시작</span>
                            <span className="text-[#919096]">클릭하여 녹음</span>
                        </button>
                    )}
                </div>
                <div id="audioWrapBoxTab" className="h-[40px] border-t border-[#e6e6e8] flex justify-center items-center gap-[20px] px-[20px]">
                    <button
                        onClick={() => setActiveTab('upload')}
                        className={`flex items-center gap-[6px] transition-colors ${activeTab === 'upload'
                            ? 'text-[#333]'
                            : 'text-[#919096]'
                            }`}
                    >
                        <MdUpload size={16} />
                    </button>
                    <button
                        onClick={() => setActiveTab('record')}
                        className={`flex items-center gap-[6px] transition-colors ${activeTab === 'record'
                            ? 'text-[#333]'
                            : 'text-[#919096]'
                            }`}
                    >
                        <HiOutlineMicrophone size={16} />
                    </button>
                </div>
            </div>
            <button className="w-full py-[10px] bg-[#e4e4e6] rounded-[5px] mt-[20px] disabled:opacity-45" disabled={!uploadedAudio}>텍스트 변환</button>
        </div>
    )
}

 

src/components/CommonPut.tsx

export default function CommonPut({ put, label, height, highHeight, hasButton }: { put: string, label: string, height: number, highHeight?: number, hasButton?: string }) {
    return (
        <div id="commonPut">
            <div id="commonPutBox" className="border border-[#e6e6e8] rounded-[5px] px-[16px] py-[10px]" style={{ height: highHeight ? `${highHeight}px` : "auto" }}>
                <h2 className="text-[13px] font-[500] mb-[10px]">{label}</h2>
                {put === "output" ? (
                    <div className="text__content w-full border border-[#e6e6e8] box-border rounded-[5px]" style={{ height: `${height}px` }}>

                    </div>
                ) : (
                    <input type="text" className="text__content px-[10px] outline-none w-full h-[112px] border border-[#d2d2d3] box-border rounded-[5px]" style={{ height: `${height}px` }}>

                    </input>
                )}
            </div>
            {hasButton && <button className="w-full py-[10px] px-[14px] bg-[#e6e6e8] text-white rounded-[5px] mt-[10px]">{hasButton}</button>}
        </div>
    )
}

 

src/components/AudioResponse.tsx

'use client';

import { useState, useRef } from "react";
import { MdSpatialAudioOff } from "react-icons/md";
import { IoCloseCircle } from "react-icons/io5";
import { MdPlayArrow, MdPause } from "react-icons/md";
import { MdSkipPrevious, MdSkipNext } from "react-icons/md";
import { IoIosMusicalNotes } from "react-icons/io";

interface AudioResponseProps {
    audioUrl?: string;
    onClear?: () => void;
}

export default function AudioResponse({ audioUrl, onClear }: AudioResponseProps) {
    const [isPlaying, setIsPlaying] = useState(false);
    const [duration, setDuration] = useState(0);
    const [currentTime, setCurrentTime] = useState(0);
    const audioRef = useRef<HTMLAudioElement | null>(null);

    const handlePlayPause = () => {
        if (audioRef.current) {
            if (isPlaying) {
                audioRef.current.pause();
            } else {
                audioRef.current.play();
            }
            setIsPlaying(!isPlaying);
        }
    };

    const handleTimeUpdate = () => {
        if (audioRef.current) {
            setCurrentTime(audioRef.current.currentTime);
        }
    };

    const handleLoadedMetadata = () => {
        if (audioRef.current) {
            setDuration(audioRef.current.duration);
        }
    };

    const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const newTime = parseFloat(e.target.value);
        setCurrentTime(newTime);
        if (audioRef.current) {
            audioRef.current.currentTime = newTime;
        }
    };

    const formatTime = (time: number) => {
        if (isNaN(time)) return "0:00";
        const minutes = Math.floor(time / 60);
        const seconds = Math.floor(time % 60);
        return `${minutes}:${seconds.toString().padStart(2, "0")}`;
    };

    const clearAudio = () => {
        setIsPlaying(false);
        setCurrentTime(0);
        onClear?.();
    };

    return (
        <div id="audioResponse">
            <div id="audioResponseBox" className="w-full border border-[#e6e6e8] rounded-[5px] flex flex-col">
                <div id="audioResponseTitle" className="w-[80px] h-[30px] border-r border-b border-[#e6e6e8] flex items-center justify-center gap-[5px]">
                    <MdSpatialAudioOff />
                    <span className="text-[12px]">답변</span>
                </div>
                <div id="audioResponseContent" className="flex-1 flex justify-center items-center p-[20px]">
                    {audioUrl ? (
                        <div className="w-[280px] flex flex-col items-center gap-[12px]">
                            {/* 음파 시각화 */}
                            <div className="w-full h-[40px] flex items-center justify-center gap-[2px] mb-[4px]">
                                {[...Array(40)].map((_, i) => (
                                    <div
                                        key={i}
                                        className="w-[1.5px] bg-[#999] rounded-full"
                                        style={{
                                            height: `${20 + Math.sin(i * 0.5 + currentTime) * 10}px`,
                                            opacity: i / 40 <= (currentTime / duration) ? 1 : 0.3,
                                        }}
                                    />
                                ))}
                            </div>

                            {/* 플레이어 컨트롤 */}
                            <div className="w-full flex items-center justify-center gap-[8px] px-[8px]">
                                <button className="text-[#999] hover:text-[#333] transition-colors">
                                    <MdSkipPrevious size={16} />
                                </button>
                                <button
                                    onClick={handlePlayPause}
                                    className="text-[#999] hover:text-[#333] transition-colors"
                                >
                                    {isPlaying ? <MdPause size={16} /> : <MdPlayArrow size={16} />}
                                </button>
                                <button className="text-[#999] hover:text-[#333] transition-colors">
                                    <MdSkipNext size={16} />
                                </button>
                                <span className="text-[10px] text-[#999] min-w-[24px]">{formatTime(currentTime)}</span>
                            </div>

                            {/* 진행률 바 */}
                            <input
                                type="range"
                                min="0"
                                max={duration || 0}
                                value={currentTime}
                                onChange={handleProgressChange}
                                className="w-full h-[2px] bg-[#ddd] rounded-full appearance-none cursor-pointer"
                                style={{
                                    background: `linear-gradient(to right, #999 0%, #999 ${(currentTime / duration) * 100}%, #ddd ${(currentTime / duration) * 100}%, #ddd 100%)`
                                }}
                            />

                            <button
                                onClick={clearAudio}
                                className="flex items-center gap-[6px] text-[#919096] hover:text-[#333] transition-colors text-[12px]"
                            >
                                <IoCloseCircle size={16} />
                                <span>제거</span>
                            </button>

                            <audio
                                ref={audioRef}
                                onTimeUpdate={handleTimeUpdate}
                                onLoadedMetadata={handleLoadedMetadata}
                                onEnded={() => setIsPlaying(false)}
                            >
                                <source src={audioUrl} type="audio/mpeg" />
                            </audio>
                        </div>
                    ) : (
                        <div className="not__answer">
                            <IoIosMusicalNotes color={"#c3c0c0ff"} />
                        </div>
                    )}
                </div>
            </div>
        </div>
    );
}

 

2단계 : 음성을 입력으로 받아 텍스트로 변환 후 출력

 

현재 음성을 업로드 후 오디오 컴포넌트에 표시되어 있는 것은 아래와 같이 구현되어 있다.

 

이제 위 파일을 인풋으로 보내어 FastAPI에서 처리하고

 

텍스트 변환 응답값을 내려받아 처리하는 함수를 만들고자 한다.

 

먼저 Transformer의 pipeline 함수에서 다음과 같은 태스크를 선언하자.

whisper = pipeline("automatic-speech-recognition", model="openai/whisper-base")

 

그리고 다음과 같은 라우터를 선언할 예정이다.

@router.post("/audio_to_text", summary="transfer audio to text")
async def audioToText(input_audio_path: str):
    pass

 

가장 먼저 로컬 서버에 업로드 된 파일을 실제 로컬 폴더의 path로 변환하는 과정을 거쳐야 한다.

file_path = input_audio_path.replace("http://localhost:8000", "").lstrip("/")
project_root = Path(__file__).parent.parent.parent.parent.parent
full_path = project_root / file_path

 

그리고 이 path를 whisper에 연결하여 결과를 확인한다.

result = whisper(str(full_path))

 

결과를 확인하기 위해 프론트엔드단에서 처리하자.

const handleTransferText = async () => {
        try {
            const response = await fetch(`http://localhost:8000/api/v1/audio_to_text?input_audio_path=${uploadedAudio}`, {
                method: "POST"
            });
            const data = await response.json();
            console.log(data)
        } catch (error) {
            console.error("Transfer failed:", error);
        }
    }

 

결과 값은 아래와 같이 디버깅 된다.

 

올바로 처리 된 것 같다. 이제 이것을 응답값으로 넘기고 프론트엔드에서

 

따로 상태 값으로 저장해 UI에 뿌려주면 될 것으로 보인다.

@router.post("/audio_to_text", summary="transfer audio to text")
async def audioToText(input_audio_path: str):

    file_path = input_audio_path.replace("http://localhost:8000", "").lstrip("/")

    project_root = Path(__file__).parent.parent.parent.parent.parent
    full_path = project_root / file_path

    # 음성 인식 처리
    result = whisper(str(full_path))

    return {"text": result["text"]}

 

이 값을 저장하기 위해 먼저 app/sound_assistant/page.tsx에서 useState 통해 상태 값을 선언한다.

const [audioToTextValue, setAudioToTextValue] = useState<string>("")

 

이것을 값을 받아 값을 바꿔주는 함수를 선언해서 넘겨주자.

const changeAudioToText = (data: string) => {
    setAudioToTextValue(data);
}
<AudioComponent changeAudioToText={changeAudioToText} />

 

이제 src/components/AudioComponent.tsx 에서 change 함수 처리를 한다.

const handleTransferText = async () => {
        try {
            const response = await fetch(`http://localhost:8000/api/v1/audio_to_text?input_audio_path=${uploadedAudio}`, {
                method: "POST"
            });
            const data = await response.json();
            changeAudioToText(data.text)
        } catch (error) {
            console.error("Transfer failed:", error);
        }
 }

 

이제 그 값을 첫번째 CommonPut.tsx 컴포넌트에 넣어주어 UI에 업데이트한다.

<CommonPut put={"output"} label={"텍스트 변환"} height={110} highHeight={150} value={audioToTextValue} />

 

그럼 다음과 같이 결과 UI가 나온다.

 

3단계 : 변환된 텍스트를 이용해 질문하기

 

이제 질문에 대한 답변을 얻기 위해 두 번째 pipeline 태스크가 필요한 상황으로 보인다.

text_generation = pipeline("text-generation")

 

이제 직접 프롬프팅을 통해 답변을 도출해내야 한다.

 

기존 텍스트 변환 내용 기반으로 답변을 해주어야 하므로, 기존 텍스트 변환 내용도 넘겨주어야 한다.

 

그래서 이전 라우팅에서 내용과 현재에서도 이 변수를 공유하려면, 전역 변수를 이용하자.

voice_txt, current_answer = ""

 

여기서 current_answer는 다음 단계에서 똑같은 과정을 거칠 것이므로 일단 참고만 해두자.

 

기존 텍스트 변환 라우터를 다음과 같이 수정하자.

@router.post("/audio_to_text", summary="transfer audio to text")
async def audioToText(input_audio_path: str):
    global voice_txt

    file_path = input_audio_path.replace("http://localhost:8000", "").lstrip("/")

    project_root = Path(__file__).parent.parent.parent.parent.parent
    full_path = project_root / file_path

    # 음성 인식 처리
    result = whisper(str(full_path))
    voice_text = result["text"]

    return {"text": result["text"]}

 

현재 질문 라우팅은 다음과 같이 설정하자.

@router.post("/text_generation", summary="text generation")
async def generate_text(question: str):
    global voice_txt

    if not voice_txt:
        return {"status": "fail", "message": "음성을 텍스트로 변환 후 질문하세요"}

    # 간단한 응답 생성 (영어 프롬프트 사용)
    prompt = f"Based on: {voice_txt[:100]}\nQuestion: {question}\nAnswer: "

    try:
        result = text_generation_pipeline(prompt, max_new_tokens=100, do_sample=True, top_p=0.95)
        generated_text = result[0]["generated_text"].strip()

        # 프롬프트 부분 제거하고 답변만 추출
        answer_start = generated_text.find("Answer:")
        if answer_start != -1:
            final_answer = generated_text[answer_start + 7:].strip()
        else:
            final_answer = generated_text

        return {
            "status": "success",
            "message": final_answer,
        }
    except Exception as e:
        return {
            "status": "fail",
            "message": f"텍스트 생성 중 오류가 발생했습니다: {str(e)}"
        }

 

이것을 토대로 프론트엔드 설정해보자.

 

먼저 questionValue를 통해 질문 데이터를 관리한다.

const [questionValue, setQuestionValue] = useState<string>("");

 

또 onChange 함수를 연결한다.

const changeQuestionValue = (e: React.ChangeEvent<HTMLInputElement>) => {
   setQuestionValue(e.target.value)
}

 

다음은 answerValue를 관리하자.

const [answerValue, setAnswerValue] = useState<string>("");

 

또, change 함수를 통해 순수함수를 구현하여, answerValue를 받아오자.

 const changeAnswerValue = (data: string) => {
    setAnswerValue(data)
 }

 

이제 PutCommon.tsx 컴포넌트에서 status가 성공할 경우에만 응답값을 받아오자.

const quest = async () => {
        try {
            const response = await fetch(`http://localhost:8000/api/v1/text_generation?question=${questionValue}`, {
                method: "POST"
            })
            const data = await response.json()
            if (data.status === "success") {
                changeAnswerValue(data.message)
                console.log(data.message)
            } else {
                console.error(data.message)
            }
        } catch (error) {
            console.log(error)
        }
 }

 

4단계 : 답변을 음성으로 듣기

 

현재 받은 답변을 기반으로 다시 음성으로 변환하는 TTS 기능을 구현해보자.

 

아까 전역 변수로 선언한 current_answer를 이용해보자.

 

먼저 TTS 함수 만들자.

async def text_to_voice(text):
    voice = "ko-KR-InJoonNeural"

    # 타임스탬프를 포함한 파일명 생성
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"answer_{timestamp}.mp3"
    filepath = ANSWER_DIR / filename

    communicate = edge_tts.Communicate(text, voice)
    await communicate.save(str(filepath))

    # 파일 URL 반환
    file_url = f"http://localhost:8000/answer/{filename}"
    return file_url

 

이걸 이용해 API 함수 만들자.

@router.post("/tts", summary="text-to-speech")
async def tts():
    global current_answer
    if not current_answer:
        return {"status": "fail", "message": "변환할 텍스트가 없습니다"}

    try:
        file_url = await text_to_voice(current_answer)
        return {
            "status": "success",
            "audio_url": file_url
        }
    except Exception as e:
        return {
            "status": "fail",
            "message": f"음성 변환 중 오류가 발생했습니다: {str(e)}"