← 전체 과정으로 돌아가기

🤖 라즈베리파이 AI 페르소나 역할대화 앱 만들기

고등학생 대상 수업 교안 · Raspberry Pi 4 로컬서버 + 노트북 Chrome/Edge + 카메라 + USB 마이크 + OpenCV + Flask + Gemini API

1. 수업 개요

수업 목표

학생이 라즈베리파이 카메라와 생성형 AI API를 연결해, 사용자를 보고 첫 인사한 뒤 선택한 역할로 대화하는 AI 페르소나 웹앱을 구현합니다.

최종 산출물

Raspberry Pi가 Flask 로컬서버가 되고, 학생 노트북이 http://라즈베리파이IP:8501로 접속하는 웹앱

카메라 관찰 결과 + 페르소나 프롬프트 + Gemini API 답변 + 음성 입력(STT) + 역할별 음성 출력(TTS)

보유 라즈베리파이 기준

  • OS: Raspbian GNU/Linux 10 Buster
  • 카메라 명령어: raspistill
  • 카메라 상태: supported=1 detected=1
  • Python 패키지: flask, requests, cv2 정상

권장 수업 시간표

단계시간활동
도입10분AI 페르소나, 카메라 입력, 개인정보 보호 원칙 설명
환경 확인20분라즈베리파이 접속, 인터넷, 카메라, Python 패키지 확인
API 키 준비20분교사용 Google AI Studio 새 프로젝트 생성 및 API 키 발급
앱 제작40분app.py 작성, API 키 환경변수 적용, Flask 앱 실행
음성 대화15분마이크 음성 인식(STT)과 역할별 음성 합성(TTS) 체험
페르소나 설계30분기본 역할과 중복되지 않는 확장 역할 프롬프트로 새로운 페르소나 만들기
발표20분앱 시연, 프롬프트 개선 전후 비교, 안전 설계 토론

2. Raspberry Pi를 로컬서버로 사용하는 수업 구조

🍓 Raspberry Pi가 담당

  • Flask 웹서버 실행
  • 카메라 촬영과 OpenCV 분석
  • USB 마이크 5초 녹음
  • Gemini 음성 받아쓰기(STT)
  • Gemini 페르소나 답변 생성

💻 학생 노트북이 담당

  • 페르소나 앱 화면 표시
  • 역할 선택과 질문 입력
  • AI 답변 표시
  • 역할별 TTS 음성 선택
  • 노트북 스피커로 음성 출력
Raspberry Pi
[카메라 + USB 마이크 + Flask + Gemini]
              │
              │ 같은 Wi-Fi / LAN
              ▼
학생 노트북 Chrome/Edge
[페르소나 화면 + 역할별 TTS + 노트북 스피커]
VNC는 관리용입니다. 학생 실습은 VNC 안의 Chromium이 아니라 노트북 자체 Chrome/Edge에서 Raspberry Pi 주소로 접속합니다.

2. 개인정보·안전 운영 원칙

이 수업의 안전한 설계

카메라 사진은 라즈베리파이 내부에서만 사람 감지, 웃는 표정 특징, 안경 착용 특징을 간단히 확인하는 데 사용합니다. 사진 파일은 /tmp에 잠깐 저장한 뒤 삭제하며, Gemini API에는 이미지가 아니라 텍스트 관찰 결과만 전달합니다.

화면 앞에 사람이 보입니다. 웃는 표정 특징이 감지되었습니다. 안경을 착용했을 가능성이 있습니다. 성별, 나이, 신원, 실제 감정 상태는 추정하지 않았습니다.

수업 중 지켜야 할 점

  • 성별, 나이, 학생 여부, 신원을 추정하지 않습니다.
  • 실제 감정 상태를 단정하지 않습니다.
  • 얼굴 사진이나 영상을 저장하지 않습니다.
  • 얼굴 사진을 외부 AI 서버로 전송하지 않습니다.
  • 실명, 전화번호, 주소 등 개인정보를 대화창에 입력하지 않습니다.
  • API 키를 사진으로 찍거나 메신저·SNS 등에 공유하지 않습니다.

3. 라즈베리파이 카메라 세팅부터 확인하기

IOT 장비는 Buster 환경이므로 최신 rpicam-hello가 아니라 raspistill을 사용합니다.

3-1. OS와 카메라 명령어 확인

cat /etc/os-release
which rpicam-hello
which libcamera-hello
which raspistill

현 IOT 환경에서는 which raspistill 결과가 아래처럼 나옵니다.

/usr/bin/raspistill

3-2. 카메라 인식 확인

vcgencmd get_camera

정상 결과:

supported=1 detected=1

3-3. 사진 촬영 테스트

raspistill -n -t 1000 -w 640 -h 480 -o test.jpg
ls -lh test.jpg
xdg-open test.jpg

3-4. Python 패키지 확인

python3 -c "import flask, requests, cv2; print('OpenCV:', cv2.__version__); print('OK')"

정상 결과:

OK
주의: 현 IOT 장비에서 apt update는 Buster 저장소 문제로 404 오류가 날 수 있습니다. 이미 flask, requests, cv2가 OK라면 이번 앱 제작은 그대로 진행하면 됩니다.
이번 업데이트의 얼굴 검출 개선: 촬영 해상도를 960×720으로 높이고, 촬영 대기시간을 늘렸으며, 히스토그램 평활화와 두 단계 얼굴 검출을 적용했습니다.

3-5. Haar Cascade 파일과 오류 방지 확인

python3 -c "import cv2; print(getattr(getattr(cv2,'data',None),'haarcascades','cv2.data 경로 없음'))"
중요: Buster의 OpenCV는 설치 방식에 따라 Haar XML 파일 위치가 다릅니다. 얼굴 XML 파일을 못 읽은 상태에서 detectMultiScale()을 실행하면 Assertion failed: !empty() 오류가 발생할 수 있습니다. 이번 업데이트본은 XML 파일 존재 여부와 CascadeClassifier.empty()를 먼저 확인합니다. 웃음·안경용 XML이 없으면 해당 특징만 건너뛰고 앱은 계속 실행합니다.

4. 수업용 Gemini API 키 이해하기

API 키란? 프로그램이 Gemini API를 사용할 수 있도록 허가하는 비밀 문자열입니다. 비밀번호와 비슷하게 다루며 다른 사람에게 공개하지 않습니다.

4-1. 이번 수업에서의 사용 방식

수업에서는 미리 준비된 수업용 API 키를 사용합니다. 학생이 개인 API 키를 새로 만들 필요는 없습니다.

4-2. API 키 보안 원칙

이번 교재의 방식: API 키는 현재 터미널 세션에서만 사용합니다. 터미널 세션이 끝나거나 Raspberry Pi를 재부팅하면 해당 환경변수는 사라집니다.

5. 수업용 API 키 사용하기

라즈베리파이 터미널에서 진행합니다. 앱이 실행 중이면 먼저 Ctrl + C로 중지합니다.

5-1. 수업용 API 키 열기 · 복사하기

🔐 수업용 키 등록 · 변경

교사가 Cloudflare 공용 서버에 API 키를 등록합니다. 학생과 교사가 다른 Wi-Fi를 사용해도 동일한 교재에서 조회할 수 있습니다.

🔑 수업용 API 키 열기

선생님께 받은 4자리 수업용 비밀번호를 입력하고 키 보기를 누르세요. 다른 Wi-Fi에 연결되어 있어도 인터넷이 되면 사용할 수 있습니다.

⚠ 수업용 API 키는 다른 사람에게 공유하지 말고 수업 종료 후 다시 숨기세요. 비밀번호만으로 조회 가능한 키는 유출 위험이 있으므로 교사는 실습 전용 키만 등록해야 합니다.

5-2. 현재 터미널에 API 키 등록하기

복사한 API 키를 아래 명령의 따옴표 안에 붙여넣습니다.

export GEMINI_API_KEY="여기에_복사한_API_키를_붙여넣기"
중요: 이 명령은 API 키를 ~/.bashrc에 저장하지 않습니다. 현재 터미널에서만 사용할 수 있습니다.

5-3. API 키 등록 확인하기

키 자체를 화면에 출력하지 않고 등록 여부만 확인합니다.

if [ -n "$GEMINI_API_KEY" ]; then echo "API 키 등록 완료"; else echo "API 키 없음"; fi

정상 결과:

API 키 등록 완료
여기까지는 실행 준비 단계입니다. API 키가 등록되었는지만 확인한 뒤, 아래의 앱 제작 과정을 계속 진행합니다. 실제 앱 실행과 API 키 삭제는 모든 제작 과정이 끝난 뒤 마지막 단계에서 진행합니다.

6. 라즈베리파이에 페르소나 앱 만들기

6-0. 기존 앱 폴더 삭제하기

rm -f ~/camera_persona_app/app.py

6-1. 앱 폴더 만들기

mkdir -p ~/camera_persona_app
cd ~/camera_persona_app

6-2. app.py 만들기

nano app.py

아래 코드를 전부 복사해서 app.py에 붙여넣습니다.

from flask import Flask, request, render_template_string, jsonify
import os
import cv2
import requests
import subprocess
import unicodedata
import re
import base64

app = Flask(__name__)
app.config["JSON_AS_ASCII"] = False

@app.after_request
def add_utf8_headers(response):
    if response.mimetype == "text/html":
        response.headers["Content-Type"] = "text/html; charset=utf-8"
    return response

GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip()
MODEL_NAME = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite")
GEMINI_URL = (
    "https://generativelanguage.googleapis.com/v1beta/models/"
    f"{MODEL_NAME}:generateContent"
)

last_observation = "아직 카메라 관찰을 하지 않았습니다."
chat_history = []
last_voice_text = ""

VOICE_SECONDS = int(os.environ.get("PERSONA_VOICE_SECONDS", "5"))

def detect_microphone_device():
    """
    Raspberry Pi가 바뀌어도 arecord -l 결과를 읽어 녹음 장치를 자동으로 선택합니다.

    우선순위
    1) PERSONA_MIC_DEVICE 환경변수
    2) USB 계열 녹음 장치
    3) 첫 번째 녹음 장치
    """
    manual = os.environ.get("PERSONA_MIC_DEVICE", "").strip()
    if manual:
        return manual, "수동 지정 장치 ({})".format(manual)

    try:
        result = subprocess.run(
            ["arecord", "-l"],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            universal_newlines=True,
            timeout=5
        )
        output = result.stdout or ""
    except Exception as e:
        return None, "arecord -l 실행 실패: {}".format(e)

    devices = []

    # 다양한 ALSA 출력 형식을 폭넓게 처리:
    # card 2: Device [USB PnP Sound Device], device 0: USB Audio [USB Audio]
    # card 1: CODEC [USB Audio CODEC], device 0: USB Audio [USB Audio]
    pattern = re.compile(
        r"card\s+(\d+)\s*:\s*([^\[]+)\[([^\]]*)\]\s*,\s*device\s+(\d+)\s*:\s*([^\[]+)(?:\[([^\]]*)\])?",
        re.IGNORECASE
    )

    for line in output.splitlines():
        match = pattern.search(line)
        if not match:
            # 최소 형식 fallback: card N ... device M
            fallback = re.search(
                r"card\s+(\d+).*?device\s+(\d+)",
                line,
                re.IGNORECASE
            )
            if fallback:
                card_no = fallback.group(1)
                device_no = fallback.group(2)
                label = line.strip()
                devices.append({
                    "card": card_no,
                    "device": device_no,
                    "label": label,
                    "line": line.lower()
                })
            continue

        card_no = match.group(1)
        short_name = (match.group(2) or "").strip()
        card_label = (match.group(3) or "").strip()
        device_no = match.group(4)
        device_name = (match.group(5) or "").strip()
        device_label = (match.group(6) or "").strip()

        label_parts = [
            p for p in [card_label, device_label, short_name, device_name]
            if p
        ]
        label = " / ".join(dict.fromkeys(label_parts))

        devices.append({
            "card": card_no,
            "device": device_no,
            "label": label,
            "line": line.lower()
        })

    if not devices:
        return None, "arecord -l에서 녹음 장치를 찾지 못했습니다."

    usb_keywords = [
        "usb",
        "pnp",
        "sound device",
        "audio codec",
        "codec",
        "c-media",
        "cmedia",
        "pcm2902",
        "microphone",
        "mic"
    ]

    # USB 계열 우선
    for d in devices:
        haystack = (d["label"] + " " + d["line"]).lower()
        if any(keyword in haystack for keyword in usb_keywords):
            dev = "plughw:{},{}".format(d["card"], d["device"])
            return dev, "{} ({})".format(d["label"], dev)

    # USB 키워드를 못 찾으면 첫 capture 장치 사용
    d = devices[0]
    dev = "plughw:{},{}".format(d["card"], d["device"])
    return dev, "{} ({})".format(d["label"], dev)


# 앱 시작 시 마이크를 한 번 자동 감지합니다.
MIC_DEVICE, MIC_LABEL = detect_microphone_device()

# 첫 화면에서도 항상 사용할 수 있도록 상태값을 전역에서 초기화합니다.
voice_status = (
    "🎤 사용 중인 마이크: " + MIC_LABEL
    if MIC_DEVICE
    else "⚠ 녹음 가능한 마이크를 자동으로 찾지 못했습니다."
)

PERSONAS = {
    "admission_consultant": """
너는 '20년차 입시전략 컨설턴트' 역할의 교육용 AI야.
고등학생과 학부모의 진로·진학·내신·수능·학생부·면접 고민에
구체적이고 현실적인 실행 방법을 제시해.
합격을 보장하지 말고 최신 모집요강 확인이 필요한 정보는 그렇게 안내해.
민감한 개인정보를 요구하지 마.
""",

    "einstein_physics": """
너는 '아인슈타인 물리 선생님'처럼 사고실험과 쉬운 비유를 활용하는 과학 멘토야.
고등학생 눈높이에서 핵심 개념 → 생활 속 예시 → 필요하면 쉬운 수식 순으로 설명해.
실제 아인슈타인의 발언을 지어내지 마.
""",

    "youth_counselor": """
너는 '20년 경력 청소년 상담사' 역할의 안전한 상담형 AI야.
판단하지 말고 차분하게 경청하며, 상황을 정리한 뒤 현실적인 선택지를 제안해.
자해·폭력·학대·긴급 위험 신호가 있으면 보호자, 학교 상담교사,
119 또는 지역 긴급 지원에 도움을 요청하도록 안내해.
전문 의료인이나 실제 상담기관을 대신하지 않는다.
""",

    "aristotle_philosopher": """
너는 아리스토텔레스의 관점으로 대화하는 철학 멘토야.
행복, 덕, 목적, 습관, 우정, 정의, 공동체를 고등학생 눈높이에 맞춰 설명해.
실제 저작의 문장을 지어내어 직접 인용하지 마.
""",

    "gandalf_wizard": """
너는 경험 많은 회색 마법사처럼 말하는 판타지 멘토야.
학생의 공부, 진로, 친구관계, 프로젝트 고민을 하나의 퀘스트처럼 바꾸어 조언해.
작품의 대사를 길게 그대로 인용하지 마.
"""
}

def capture_image():
    """
    Raspberry Pi OS Buster에서는 raspistill을 사용합니다.
    촬영 이미지는 /tmp에 잠깐 저장하고 분석 후 삭제합니다.
    """
    image_path = "/tmp/persona_camera_capture.jpg"

    cmd = [
        "raspistill",
        "-n",
        "-t", "1400",
        "-w", "960",
        "-h", "720",
        "-o", image_path
    ]

    subprocess.run(cmd, check=True, timeout=12)
    return image_path


def find_cascade(filename):
    """
    Buster의 OpenCV는 설치 방식에 따라 haarcascade 위치가 다를 수 있습니다.
    여러 후보 경로를 순서대로 확인합니다.
    """
    candidates = []

    if hasattr(cv2, "data") and hasattr(cv2.data, "haarcascades"):
        candidates.append(os.path.join(cv2.data.haarcascades, filename))

    candidates.extend([
        os.path.join("/usr/share/opencv/haarcascades", filename),
        os.path.join("/usr/share/opencv4/haarcascades", filename),
        os.path.join("/usr/local/share/opencv/haarcascades", filename),
        os.path.join("/usr/local/share/opencv4/haarcascades", filename),
    ])

    for path in candidates:
        if path and os.path.exists(path):
            return path

    return ""


def load_cascade(filename):
    path = find_cascade(filename)

    if not path:
        return None

    cascade = cv2.CascadeClassifier(path)

    if cascade.empty():
        return None

    return cascade


def analyze_image_locally(image_path):
    """
    Raspberry Pi 내부에서만 단순한 시각 특징을 분석합니다.
    얼굴 사진 자체는 Gemini API로 보내지 않습니다.
    """
    image = cv2.imread(image_path)

    if image is None:
        return "카메라 이미지를 읽지 못했습니다."

    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    face_cascade = load_cascade("haarcascade_frontalface_default.xml")
    smile_cascade = load_cascade("haarcascade_smile.xml")
    eyeglasses_cascade = load_cascade("haarcascade_eye_tree_eyeglasses.xml")

    if face_cascade is None:
        return (
            "얼굴 감지용 Haar Cascade 파일을 찾거나 읽지 못했습니다. "
            "카메라 촬영은 되었지만 얼굴 특징 분석은 실행하지 않았습니다."
        )

    gray_eq = cv2.equalizeHist(gray)

    faces = face_cascade.detectMultiScale(
        gray_eq,
        scaleFactor=1.08,
        minNeighbors=4,
        minSize=(45, 45)
    )

    if len(faces) == 0:
        faces = face_cascade.detectMultiScale(
            gray_eq,
            scaleFactor=1.05,
            minNeighbors=3,
            minSize=(35, 35)
        )

    person_detected = len(faces) > 0
    smile_like = None
    glasses_like = None

    if person_detected:
        x, y, w, h = max(faces, key=lambda r: r[2] * r[3])
        roi_gray = gray_eq[y:y+h, x:x+w]

        if smile_cascade is not None:
            smiles = smile_cascade.detectMultiScale(
                roi_gray,
                scaleFactor=1.7,
                minNeighbors=20,
                minSize=(25, 25)
            )
            smile_like = len(smiles) > 0

        if eyeglasses_cascade is not None:
            upper_face = roi_gray[0:int(h * 0.65), :]
            eye_glasses = eyeglasses_cascade.detectMultiScale(
                upper_face,
                scaleFactor=1.1,
                minNeighbors=5,
                minSize=(20, 20)
            )
            glasses_like = len(eye_glasses) >= 1

    if person_detected:
        results = ["화면 앞에 사람이 보입니다."]

        if smile_like is True:
            results.append("웃는 표정 특징이 감지되었습니다.")
        elif smile_like is False:
            results.append("웃는 표정 특징은 뚜렷하게 감지되지 않았습니다.")
        else:
            results.append("웃는 표정 분석용 파일이 없어 웃음 특징은 분석하지 않았습니다.")

        if glasses_like is True:
            results.append("안경을 착용했을 가능성이 있습니다.")
        elif glasses_like is False:
            results.append("안경 착용 특징은 뚜렷하게 감지되지 않았습니다.")
        else:
            results.append("안경 분석용 파일이 없어 안경 특징은 분석하지 않았습니다.")

        results.append(
            "성별, 나이, 신원, 건강 상태, 실제 감정 상태는 추정하지 않았습니다."
        )
        return " ".join(results)

    return (
        "화면 앞에 사람이 뚜렷하게 보이지 않습니다. "
        "카메라를 정면으로 바라보고 얼굴이 화면의 1/4 이상 보이도록 가까이 와 주세요. "
        "역광이 강하면 조명을 조정해 주세요."
    )


def observe_camera():
    image_path = None

    try:
        image_path = capture_image()
        return analyze_image_locally(image_path)

    except Exception as e:
        return f"카메라 관찰 중 오류가 발생했습니다: {e}"

    finally:
        if image_path and os.path.exists(image_path):
            os.remove(image_path)


def capture_voice():
    """
    Chromium Web Speech API를 사용하지 않고 Raspberry Pi의 USB 마이크를
    arecord로 직접 5초간 녹음합니다.
    """
    if not MIC_DEVICE:
        raise RuntimeError(
            "녹음 가능한 마이크를 자동으로 찾지 못했습니다. "
            "터미널에서 arecord -l을 확인하거나 PERSONA_MIC_DEVICE를 수동 지정해 주세요."
        )

    audio_path = "/tmp/persona_voice.wav"

    cmd = [
        "arecord",
        "-D", MIC_DEVICE,
        "-f", "S16_LE",
        "-r", "16000",
        "-c", "1",
        "-d", str(VOICE_SECONDS),
        "-t", "wav",
        audio_path
    ]

    result = subprocess.run(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        timeout=VOICE_SECONDS + 5
    )

    if result.returncode != 0:
        error_text = result.stderr.decode("utf-8", errors="ignore").strip()
        raise RuntimeError(
            "USB 마이크 녹음에 실패했습니다. "
            "장치=" + MIC_DEVICE + " / " + error_text
        )

    if not os.path.exists(audio_path) or os.path.getsize(audio_path) < 1000:
        raise RuntimeError("녹음 파일이 생성되지 않았거나 너무 작습니다.")

    return audio_path


def transcribe_voice_with_gemini(audio_path):
    """
    녹음된 WAV 파일만 Gemini API로 보내 한국어 텍스트로 변환합니다.
    카메라 이미지는 계속 외부로 보내지 않습니다.
    """
    if not GEMINI_API_KEY:
        return "", "GEMINI_API_KEY가 설정되지 않았습니다."

    with open(audio_path, "rb") as f:
        audio_b64 = base64.b64encode(f.read()).decode("ascii")

    headers = {
        "Content-Type": "application/json",
        "x-goog-api-key": GEMINI_API_KEY
    }

    data = {
        "contents": [
            {
                "parts": [
                    {
                        "text": (
                            "다음 한국어 음성을 정확하게 받아쓰기 해줘. "
                            "설명이나 따옴표를 붙이지 말고, 들린 문장만 한국어 텍스트로 출력해."
                        )
                    },
                    {
                        "inlineData": {
                            "mimeType": "audio/wav",
                            "data": audio_b64
                        }
                    }
                ]
            }
        ]
    }

    try:
        response = requests.post(
            GEMINI_URL,
            headers=headers,
            json=data,
            timeout=45
        )

        if response.status_code != 200:
            return "", (
                "Gemini 음성인식 오류: "
                + str(response.status_code)
                + " / "
                + response.text[:300]
            )

        result = response.json()

        # Gemini 응답의 parts 안에서 실제 text를 안전하게 찾아 합칩니다.
        text_parts = []
        candidates = result.get("candidates") or []
        for candidate in candidates:
            content = candidate.get("content") or {}
            for part in content.get("parts") or []:
                part_text = part.get("text")
                if isinstance(part_text, str) and part_text.strip():
                    text_parts.append(part_text.strip())

        text = " ".join(text_parts).strip()

        if not text:
            finish_reason = ""
            if candidates:
                finish_reason = str(candidates[0].get("finishReason", ""))
            return "", (
                "⚠ 음성은 녹음되었지만 받아쓰기 결과가 없습니다."
                + (" / finishReason=" + finish_reason if finish_reason else "")
            )

        # 따옴표만 감싼 응답은 입력창에 자연스럽게 들어가도록 정리합니다.
        if len(text) >= 2 and text[0] == text[-1] and text[0] in ('"', "'"):
            text = text[1:-1].strip()

        return text, "✅ 음성 인식 완료: " + text

    except Exception as e:
        return "", "❌ Gemini 음성인식 중 오류가 발생했습니다: " + str(e)


def recognize_voice():
    audio_path = None

    try:
        audio_path = capture_voice()
        return transcribe_voice_with_gemini(audio_path)

    except Exception as e:
        return "", "음성 녹음/인식 오류: " + str(e)

    finally:
        if audio_path and os.path.exists(audio_path):
            os.remove(audio_path)


def build_prompt(persona_text, observation, user_message):
    history_text = ""

    for speaker, msg in chat_history[-8:]:
        history_text += f"{speaker}: {msg}\n"

    return f"""
너는 아래 페르소나 역할을 유지하면서 고등학생과 역할대화를 한다.

[페르소나 역할]
{persona_text}

[현재 카메라 관찰 결과]
{observation}

[카메라 관찰 사용 규칙]
- 카메라 관찰 결과에 적힌 범위 안에서만 시각적 특징을 답해.
- 사용자가 "안경을 썼어?", "웃고 있어?"처럼 물으면 관찰 결과를 그대로 자연스럽게 설명해.
- 안경 판별은 Haar 기반의 간단한 추정이므로 "착용 가능성이 있습니다" 또는 "뚜렷하게 감지되지 않았습니다"처럼 확률적으로 표현해.
- 웃음 검출은 표정 특징일 뿐 실제 감정 상태라고 단정하지 마.
- 성별, 나이, 학생 여부, 신원, 건강 상태, 실제 감정 상태는 얼굴만 보고 추정하지 마.
- 얼굴 이미지나 개인정보를 저장하거나 요구하지 마.
- 답변은 5문장 이내로 해.
- 역할을 유지하면서 자연스럽게 답변해.
- 위험하거나 민감한 고민은 보호자, 학교 상담교사, 관련 전문가에게 도움을 요청하도록 안내해.

[이전 대화]
{history_text}

[사용자 입력]
{user_message}
"""

def ask_gemini(persona_text, observation, user_message):
    if not GEMINI_API_KEY:
        return (
            "GEMINI_API_KEY가 설정되지 않았습니다. "
            "CMD에서 환경변수를 먼저 설정해 주세요."
        )

    headers = {
        "Content-Type": "application/json",
        "x-goog-api-key": GEMINI_API_KEY
    }

    data = {
        "contents": [
            {
                "parts": [
                    {
                        "text": build_prompt(
                            persona_text,
                            observation,
                            user_message
                        )
                    }
                ]
            }
        ]
    }

    try:
        response = requests.post(
            GEMINI_URL,
            headers=headers,
            json=data,
            timeout=30
        )

        if response.status_code != 200:
            return f"Gemini API 오류: {response.status_code}\n{response.text}"

        result = response.json()
        return result["candidates"][0]["content"]["parts"][0]["text"]

    except Exception as e:
        return f"Gemini 호출 중 오류가 발생했습니다: {e}"

HTML = r"""
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>라즈베리파이 AI 페르소나 역할대화 앱</title>
<style>
body{
    margin:0;
    font-family:"Noto Sans KR","Malgun Gothic",Arial,sans-serif;
    background:#f3f6fb;
    color:#172033;
}
.container{
    max-width:920px;
    margin:28px auto;
    background:white;
    border-radius:22px;
    padding:30px;
    box-shadow:0 8px 28px rgba(0,0,0,.12);
}
h1{text-align:center;margin-top:0}
.notice{
    background:#eef5ff;
    padding:16px;
    border-radius:14px;
    line-height:1.7;
    margin-bottom:20px;
}
label{
    font-weight:800;
    display:block;
    margin-top:18px;
    margin-bottom:8px;
}
select,textarea{
    width:100%;
    box-sizing:border-box;
    border:1px solid #cfd6e4;
    border-radius:12px;
    padding:12px;
    font-size:16px;
}
textarea{min-height:105px}
button{
    margin-top:15px;
    padding:14px 18px;
    border:none;
    border-radius:12px;
    font-size:16px;
    cursor:pointer;
    background:#315cf6;
    color:white;
    font-weight:800;
}
.sub-button{background:#5b667a}
.row{display:flex;gap:10px;flex-wrap:wrap}
.observation{
    background:#fff7e6;
    border-radius:14px;
    padding:16px;
    line-height:1.7;
    margin:18px 0;
}
.chat-box{
    background:#f7f8fc;
    border-radius:14px;
    padding:18px;
    margin-top:20px;
    min-height:120px;
}
.msg{
    padding:12px;
    margin:8px 0;
    border-radius:12px;
    line-height:1.65;
    white-space:pre-wrap;
}
.user{background:#e8edff}
.ai{background:#e8fff1}
.voice-panel{
    margin-top:12px;
    padding:14px;
    border-radius:14px;
    background:#f0f5ff;
    border:1px solid #d7e1ff;
}
.voice-button{background:#8a3ffc}
.stop-button{background:#d94b4b}
.voice-status{
    display:inline-block;
    margin-left:10px;
    font-size:14px;
    color:#526079;
}
.voice-option{
    display:flex;
    align-items:center;
    gap:8px;
    margin-top:12px;
    font-weight:700;
}
.voice-option input{width:auto}
.voice-select{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:10px}
.voice-select label{font-size:13px;color:#526079}
.voice-select select{width:100%;margin-top:5px}
.voice-note{margin-top:8px;font-size:12px;color:#667085}
</style>
</head>
<body>
<div class="container">
<h1>🤖 라즈베리파이 AI 페르소나 역할대화 앱</h1>

<div class="notice">
이 앱은 카메라 사진을 외부 AI로 보내지 않습니다.<br>
라즈베리파이 내부에서만 사람이 보이는지, 웃는 표정 특징이 있는지, 안경 착용 특징이 있는지를 간단히 확인하고,
그 결과를 텍스트로 바꾸어 페르소나 대화에 사용합니다. 안경 판별은 간단한 Haar 기반 추정이므로 100% 정확하지 않습니다.<br>
마이크 버튼을 누르면 Raspberry Pi가 USB 마이크를 직접 녹음하고 Gemini가 음성을 글로 바꾸어 질문합니다. AI 답변 음성 읽기는 접속한 노트북의 Chrome/Edge가 담당하며 노트북 스피커로 출력됩니다.
</div>

<form method="post">
<label>페르소나 역할 선택</label>
<select name="role">
<option value="admission_consultant" {% if role == "admission_consultant" %}selected{% endif %}>20년차 입시전략 컨설턴트</option>
<option value="einstein_physics" {% if role == "einstein_physics" %}selected{% endif %}>아인슈타인 물리 선생님</option>
<option value="youth_counselor" {% if role == "youth_counselor" %}selected{% endif %}>20년 경력 청소년 상담사</option>
<option value="aristotle_philosopher" {% if role == "aristotle_philosopher" %}selected{% endif %}>아리스토텔레스 철학 멘토</option>
<option value="gandalf_wizard" {% if role == "gandalf_wizard" %}selected{% endif %}>경험 많은 회색 마법사</option>
</select>

<label>직접 만든 역할 프롬프트 (선택)</label>
<textarea name="custom_persona"
placeholder="드롭다운 5개 외의 새로운 역할을 직접 만들 때 사용합니다. 비워두면 위에서 선택한 기본 역할이 적용됩니다.">{{ custom_persona }}</textarea>

<div class="row">
<button type="submit" name="action" value="observe">카메라로 보고 첫 인사하기</button>
<button class="sub-button" type="submit" name="action" value="reset">대화 초기화</button>
</div>

<div class="observation">
<strong>카메라 관찰 결과</strong><br>
{{ observation }}
</div>

<label>역할대화 입력</label>
<textarea id="messageInput" name="message"
placeholder="직접 입력하거나 🎤 5초 음성 질문 녹음을 사용하세요. 예: 내가 안경을 쓰고 있어? / 지금 웃고 있어? / 오늘 나랑 어떤 미션을 할 거야?">{{ last_voice_text }}</textarea>

<div class="voice-panel">
    <div class="row">
        <button id="micButton" class="voice-button" type="submit"
                name="action" value="voice">🎤 5초 음성 질문 녹음</button>
    </div>
    <span id="voiceStatus" class="voice-status">{{ voice_status }}</span>
    <div class="voice-note">
        앱은 실행할 때마다 <code>arecord -l</code>을 확인해 USB 녹음 장치를 자동으로 선택합니다. 따라서 라즈베리파이가 바뀌어 카드 번호가 <code>card 1</code>, <code>card 2</code>, <code>card 3</code>처럼 달라져도 자동으로 <code>plughw:카드번호,장치번호</code>를 구성합니다. 버튼을 누른 직후 마이크에 말하세요. Raspberry Pi가 USB 마이크를 직접 녹음한 뒤
        Gemini가 음성을 글자로 변환합니다. Chromium의 마이크 권한은 사용하지 않습니다.
    </div>
    <div class="voice-select">
        <label>음성 선택 방식
            <select id="voiceMode">
                <option value="auto" selected>역할별 자동 선택</option>
                <option value="manual">직접 선택</option>
            </select>
        </label>

        <label>사용할 목소리
            <select id="voiceSelect"></select>
        </label>
    </div>

    <div class="voice-note">
        자동 선택은 역할에 따라 음성·말하기 속도·높낮이를 다르게 적용합니다.
        사용 가능한 음성은 PC와 브라우저에 따라 달라질 수 있습니다.
    </div>

    <label class="voice-option">
        <input id="autoSpeak" type="checkbox" checked>
        AI 답변 자동으로 읽기
    </label>

    <div class="row" style="margin-top:12px">
        <button id="speakLastButton" class="voice-button" type="button">🔊 마지막 AI 답변 듣기</button>
        <button id="stopSpeakButton" class="stop-button" type="button">🔇 음성 중지</button>
                <button id="endClassButton" type="button"
                        style="background:#263238;color:white;border:none;border-radius:10px;padding:12px 16px;font-weight:700;cursor:pointer;margin-left:6px">
                    🔐 수업 종료 · API 키 삭제
                </button>
    </div>
    <div class="voice-note">
        자동 읽기가 브라우저 정책 때문에 막힐 경우 <b>마지막 AI 답변 듣기</b>를 누르세요.
        이 버튼은 사용자의 직접 클릭으로 실행되므로 노트북 Chrome/Edge에서 더 안정적입니다.
    </div>
</div>

<button id="chatButton" type="submit" name="action" value="chat">페르소나와 대화하기</button>
</form>

<div class="chat-box">
<strong>대화 내용</strong>
{% for speaker, msg in chat_history %}
    {% if speaker == "사용자" %}
        <div class="msg user"><strong>사용자</strong><br>{{ msg }}</div>
    {% else %}
        <div class="msg ai"><strong>AI 페르소나</strong><br>{{ msg }}</div>
    {% endif %}
{% endfor %}
</div>
</div>

<script>
const messageInput = document.getElementById('messageInput');
const micButton = document.getElementById('micButton');
const chatButton = document.getElementById('chatButton');
const voiceStatus = document.getElementById('voiceStatus');
const autoSpeak = document.getElementById('autoSpeak');
const speakLastButton = document.getElementById('speakLastButton');
const stopSpeakButton = document.getElementById('stopSpeakButton');
const voiceMode = document.getElementById('voiceMode');
const voiceSelect = document.getElementById('voiceSelect');
const roleSelect = document.querySelector('select[name="role"]');
let availableVoices = [];

function loadVoices() {
    availableVoices = window.speechSynthesis.getVoices();
    voiceSelect.innerHTML = '';

    const korean = availableVoices.filter(v =>
        (v.lang || '').toLowerCase().startsWith('ko')
    );
    const list = korean.length ? korean : availableVoices;

    if (!list.length) {
        const option = document.createElement('option');
        option.value = '';
        option.textContent = '기본 시스템 음성';
        voiceSelect.appendChild(option);
        voiceSelect.disabled = false;
        return;
    }

    list.forEach(v => {
        const option = document.createElement('option');
        option.value = v.name;
        option.textContent = `${v.name} (${v.lang})`;
        voiceSelect.appendChild(option);
    });
}

const roleVoiceProfiles = {
    admission_consultant: {rate:1.00, pitch:1.00, keywords:['SunHi','선희','Heami','혜미','Google 한국어','Korean']},
    einstein_physics:      {rate:0.90, pitch:0.86, keywords:['Heami','혜미','SunHi','선희','Korean']},
    youth_counselor:       {rate:0.92, pitch:1.05, keywords:['SunHi','선희','Google 한국어','Heami','혜미']},
    aristotle_philosopher: {rate:0.84, pitch:0.80, keywords:['Heami','혜미','Korean','SunHi','선희']},
    gandalf_wizard:        {rate:0.80, pitch:0.72, keywords:['Heami','혜미','Korean','SunHi','선희']}
};

function findVoice(keywords) {
    const korean = availableVoices.filter(v =>
        (v.lang || '').toLowerCase().startsWith('ko')
    );
    const pool = korean.length ? korean : availableVoices;

    for (const keyword of keywords) {
        const found = pool.find(v =>
            v.name.toLowerCase().includes(keyword.toLowerCase())
        );
        if (found) return found;
    }
    return pool[0] || null;
}

function currentProfile() {
    const role = roleSelect ? roleSelect.value : '';
    return roleVoiceProfiles[role] || {
        rate:1.0, pitch:1.0,
        keywords:['SunHi','선희','Google 한국어','Heami','혜미','Korean']
    };
}

function updateVoiceSelection() {
    if (!voiceSelect.options.length) return;
    if (voiceMode.value === 'auto') {
        const voice = findVoice(currentProfile().keywords);
        if (voice) voiceSelect.value = voice.name;
        voiceSelect.disabled = true;
    } else {
        voiceSelect.disabled = false;
    }
}

loadVoices();
if ('speechSynthesis' in window) {
    window.speechSynthesis.onvoiceschanged = () => {
        loadVoices();
        updateVoiceSelection();
    };
}
voiceMode.addEventListener('change', updateVoiceSelection);
if (roleSelect) roleSelect.addEventListener('change', updateVoiceSelection);
setTimeout(() => { loadVoices(); updateVoiceSelection(); }, 300);
setTimeout(() => { loadVoices(); updateVoiceSelection(); }, 1200);
setTimeout(() => { loadVoices(); updateVoiceSelection(); }, 2500);

function speakText(text) {
    if (!text || !text.trim()) {
        voiceStatus.textContent = '읽을 AI 답변이 없습니다.';
        return;
    }

    if (!('speechSynthesis' in window)) {
        voiceStatus.textContent = '이 브라우저는 음성 합성을 지원하지 않습니다.';
        return;
    }

    window.speechSynthesis.cancel();

    const utterance = new SpeechSynthesisUtterance(text);
    const profile = currentProfile();

    if (voiceMode.value === 'manual') {
        const voice = availableVoices.find(v => v.name === voiceSelect.value);
        if (voice) {
            utterance.voice = voice;
            utterance.lang = voice.lang || 'ko-KR';
        } else {
            utterance.lang = 'ko-KR';
        }
        utterance.rate = 1.0;
        utterance.pitch = 1.0;
    } else {
        const voice = findVoice(profile.keywords);
        if (voice) {
            utterance.voice = voice;
            utterance.lang = voice.lang || 'ko-KR';
        } else {
            utterance.lang = 'ko-KR';
        }
        utterance.rate = profile.rate;
        utterance.pitch = profile.pitch;
    }

    utterance.onstart = () => {
        voiceStatus.textContent = '🔊 노트북 스피커로 AI 답변을 읽는 중...';
    };
    utterance.onend = () => {
        voiceStatus.textContent = '✅ AI 답변 읽기 완료';
    };
    utterance.onerror = (e) => {
        const reason = e.error || '';

        if (reason === 'interrupted' || reason === 'canceled') {
            return;
        }

        voiceStatus.textContent = '음성 출력 오류: ' + (reason || '알 수 없는 오류');
    };

    window.speechSynthesis.resume();
    window.speechSynthesis.speak(utterance);
}




if (micButton) {
    micButton.addEventListener('click', () => {
        // 현재 읽고 있는 AI 음성을 즉시 중지합니다.
        if ('speechSynthesis' in window) {
            window.speechSynthesis.cancel();
        }

        // 녹음 폼 제출 후 페이지가 다시 열릴 때
        // 이전 AI 답변을 자동으로 다시 읽지 않도록 1회 억제합니다.
        sessionStorage.setItem('suppressAutoSpeakOnce', '1');

        voiceStatus.textContent = '🎙 5초 녹음을 시작합니다. 지금 말씀하세요.';
    });
}

function getLatestAiText() {
    const aiMessages = document.querySelectorAll('.msg.ai');
    if (!aiMessages.length) return '';

    const latest = aiMessages[aiMessages.length - 1].cloneNode(true);
    const strong = latest.querySelector('strong');
    if (strong) strong.remove();

    return latest.textContent.trim();
}

speakLastButton.addEventListener('click', () => {
    loadVoices();
    updateVoiceSelection();
    speakText(getLatestAiText());
});

stopSpeakButton.addEventListener('click', () => {
    if ('speechSynthesis' in window) {
        window.speechSynthesis.cancel();
    }
    voiceStatus.textContent = '🔇 음성 출력을 중지했습니다.';
});

window.addEventListener('load', () => {
    loadVoices();
    updateVoiceSelection();

    const suppressOnce = sessionStorage.getItem('suppressAutoSpeakOnce') === '1';

    if (suppressOnce) {
        sessionStorage.removeItem('suppressAutoSpeakOnce');

        // Flask가 전달한 실제 voice_status를 그대로 유지합니다.
        // 이전 버전은 실패해도 여기서 무조건 "변환되었습니다"라고 덮어써서
        // 입력칸은 비어 있는데 성공한 것처럼 보이는 문제가 있었습니다.
        const input = document.getElementById('messageInput');
        if (input && input.value.trim()) {
            input.focus();
            input.setSelectionRange(input.value.length, input.value.length);
        }
        return;
    }

    if (autoSpeak.checked) {
        const text = getLatestAiText();
        if (text) {
            setTimeout(() => speakText(text), 500);
        }
    }
});

document.querySelectorAll('textarea').forEach(function(area) {
    area.addEventListener('paste', function(event) {
        var clipboard = event.clipboardData || window.clipboardData;
        if (!clipboard) return;

        var text = clipboard.getData('text/plain');
        if (!text) return;

        if (/\\(?:u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8})/.test(text)) {
            event.preventDefault();

            text = text.replace(/\\U([0-9a-fA-F]{8})/g, function(_, hex) {
                try { return String.fromCodePoint(parseInt(hex, 16)); }
                catch (e) { return _; }
            });
            text = text.replace(/\\u([0-9a-fA-F]{4})/g, function(_, hex) {
                return String.fromCharCode(parseInt(hex, 16));
            });

            var start = typeof area.selectionStart === 'number'
                ? area.selectionStart : area.value.length;
            var end = typeof area.selectionEnd === 'number'
                ? area.selectionEnd : area.value.length;

            area.value =
                area.value.slice(0, start) + text + area.value.slice(end);
        }
    });
});


const endClassButton = document.getElementById('endClassButton');

if (endClassButton) {
    endClassButton.addEventListener('click', async () => {
        const ok = confirm('수업을 종료하고 현재 앱 메모리의 API 키를 삭제할까요?');
        if (!ok) return;

        try {
            if ('speechSynthesis' in window) {
                window.speechSynthesis.cancel();
            }

            const response = await fetch('/end_class', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'}
            });

            const contentType = response.headers.get('content-type') || '';
            if (!response.ok) {
                const serverText = await response.text();
                throw new Error('서버 응답 오류 ' + response.status +
                    (serverText ? ' - Flask 터미널의 오류 내용을 확인하세요.' : ''));
            }

            if (!contentType.includes('application/json')) {
                const serverText = await response.text();
                throw new Error('서버가 JSON 대신 다른 형식으로 응답했습니다. app.py를 최신 코드로 다시 실행하세요.');
            }

            const data = await response.json();

            if (data.ok) {
                if (voiceStatus) {
                    voiceStatus.textContent = '🔐 수업 종료: API 키가 앱 메모리에서 삭제되었습니다.';
                }
                endClassButton.disabled = true;
                endClassButton.textContent = '✅ API 키 삭제 완료';
                alert('API 키가 현재 앱 메모리에서 삭제되었습니다.\n터미널에서 Ctrl + C로 서버를 종료하세요.');
            } else {
                alert(data.message || 'API 키 삭제에 실패했습니다.');
            }
        } catch (err) {
            alert('API 키 삭제 처리 중 오류가 발생했습니다: ' + err);
        }
    });
}

</script>

</body>
</html>
"""


def decode_unicode_escape_text(text):
    """
    일부 구형 Chromium/클립보드 환경에서 \\uXXXX 형태로 들어온 한글을
    실제 유니코드 문자로 복원합니다.
    """
    if not text:
        return text

    text = re.sub(
        r"\\U([0-9a-fA-F]{8})",
        lambda match: chr(int(match.group(1), 16)),
        text
    )
    text = re.sub(
        r"\\u([0-9a-fA-F]{4})",
        lambda match: chr(int(match.group(1), 16)),
        text
    )
    return unicodedata.normalize("NFC", text)



def clear_api_key_memory():
    """수업 종료 시 현재 Flask 프로세스에서만 API 키를 제거합니다."""
    global GEMINI_API_KEY
    GEMINI_API_KEY = ""
    os.environ.pop("GEMINI_API_KEY", None)
    return True


@app.route("/end_class", methods=["POST"])
def end_class():
    clear_api_key_memory()
    return jsonify(
        ok=True,
        message="수업용 API 키를 현재 앱 메모리에서 삭제했습니다."
    )

@app.route("/", methods=["GET", "POST"])
def index():
    global voice_status
    global last_observation, chat_history, last_voice_text, voice_status

    role = request.form.get("role", "admission_consultant")
    custom_persona = request.form.get("custom_persona", "").strip()
    custom_persona = decode_unicode_escape_text(custom_persona)
    action = request.form.get("action", "")

    persona_text = custom_persona or PERSONAS.get(
        role,
        PERSONAS["admission_consultant"]
    )

    if action == "voice":
        if MIC_DEVICE:
            voice_status = "🎙 {} 로 5초 녹음 중입니다.".format(MIC_LABEL)
        else:
            voice_status = "⚠ 녹음 가능한 마이크를 찾지 못했습니다."

        # 5초 녹음 버튼은 "음성 질문을 텍스트로 바꾸는 것"까지만 수행합니다.
        # AI 답변 생성은 아래의 "페르소나와 대화하기" 버튼을 눌렀을 때만 실행합니다.
        recognized_text, status_text = recognize_voice()
        voice_status = status_text

        # 받아쓰기에 성공했을 때만 역할대화 입력칸을 갱신합니다.
        # 실패 시에는 기존 입력을 지우지 않고 실제 오류 메시지를 화면에 표시합니다.
        if recognized_text and recognized_text.strip():
            last_voice_text = recognized_text.strip()

    elif action == "observe":
        last_observation = observe_camera()
        greeting_request = (
            "카메라 관찰 결과를 바탕으로 "
            "페르소나 역할을 유지하면서 첫 인사를 해줘."
        )
        ai_answer = ask_gemini(
            persona_text,
            last_observation,
            greeting_request
        )
        chat_history.append(("AI", ai_answer))

    elif action == "chat":
        message = request.form.get("message", "").strip()
        message = decode_unicode_escape_text(message)

        if message:
            last_voice_text = message
            chat_history.append(("사용자", message))
            ai_answer = ask_gemini(
                persona_text,
                last_observation,
                message
            )
            chat_history.append(("AI", ai_answer))

    elif action == "reset":
        chat_history = []
        last_observation = (
            "대화가 초기화되었습니다. "
            "다시 카메라 관찰을 시작할 수 있습니다."
        )

    return render_template_string(
        HTML,
        role=role,
        custom_persona=custom_persona,
        observation=last_observation,
        chat_history=chat_history,
        last_voice_text=last_voice_text,
        voice_status=voice_status
    )

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8501)

6-3. 저장하고 나가기

음성 질문이 입력칸에 나오지 않을 때

이번 수정본에서 해결됨: 이전 버전은 음성인식이 실제로 실패해도 브라우저 JavaScript가 상태 문구를 무조건 “음성 질문이 텍스트로 변환되었습니다”로 바꾸는 문제가 있었습니다. 그래서 역할대화 입력칸은 비어 있는데 성공한 것처럼 표시될 수 있었습니다.

이제 성공하면 받아쓴 문장이 역할대화 입력 칸에 자동으로 들어가고, 실패하면 성공 문구로 덮어쓰지 않고 Gemini/녹음의 실제 오류 내용을 그대로 표시합니다. 실패했을 때 기존 입력 내용도 지우지 않습니다.

7. 앱 실행과 접속

7-1. 앱 실행

cd ~/camera_persona_app
python3 app.py

정상 실행 문구:

* Running on http://0.0.0.0:8501/
이 터미널은 앱 서버입니다. 닫거나 Ctrl + C를 누르면 앱이 꺼집니다.

7-2. 라즈베리파이에서 접속

http://localhost:8501

7-3. 학생 노트북 Chrome/Edge에서 접속

라즈베리파이 IP를 확인합니다.

hostname -I

예를 들어 라즈베리파이 Wi-Fi IP가 172.30.1.84이면 노트북 브라우저에서 아래 주소로 접속합니다.

http://172.30.1.84:8501

7-4. 학생 사용 순서

  1. VNC 속 Chromium이 아니라 학생 노트북 자체의 Chrome/Edge에서 Raspberry Pi 주소로 접속합니다.
  2. 페르소나 역할을 선택합니다.
  3. 필요하면 직접 만든 페르소나 프롬프트를 붙여넣습니다.
  4. 카메라로 보고 첫 인사하기를 누릅니다.
  5. 카메라 관찰 결과와 AI 첫 인사를 확인합니다.
  6. 질문을 직접 입력하거나 🎤 음성으로 말하기를 눌러 질문합니다.
  7. AI 답변은 화면에 표시되며, 설정에 따라 역할별 목소리로 읽어줍니다.
학생별 자동 설정: 학생마다 Raspberry Pi와 USB 마이크가 달라도 python3 app.py 실행 시 arecord -l을 자동 분석합니다. USB 계열 Capture 장치를 우선 선택하고, USB가 없으면 첫 번째 녹음 장치를 사용합니다. 따라서 일반적인 수업에서는 학생이 카드 번호를 직접 입력할 필요가 없습니다.

7-5. USB 마이크 장치 확인

이번 버전에서는 학생마다 마이크 카드 번호가 달라도 앱 시작 시 arecord -l 결과를 읽어 자동으로 녹음 장치를 선택합니다.

arecord -l

정상 예:

card 2: Device [USB PnP Sound Device], device 0: USB Audio [USB Audio]
card 1: USBMic [USB Audio], device 0: USB Audio [USB Audio]
card 3: Device [USB Audio], device 0: USB Audio [USB Audio]

녹음 테스트:

arecord -l

앱 화면에 예를 들어 아래처럼 표시되면 자동 감지가 정상입니다.

🎤 마이크 자동 감지: Device / card 2 device 0
대부분의 학생은 환경변수 설정이 필요 없습니다. 자동 감지가 잘못된 특정 장비에서만 아래처럼 수동 지정합니다.
export PERSONA_MIC_DEVICE="plughw:1,0"

8. 음성으로 페르소나와 대화하기

🎤 STT · 음성 → 문자

학생이 마이크로 말하면 Raspberry Pi의 arecord가 USB 마이크 음성을 5초간 WAV 파일로 녹음하고, Gemini API가 이 음성을 한국어 텍스트로 변환합니다. 따라서 Buster의 Chromium Web Speech API에서 발생하던 network 오류를 피할 수 있습니다.

🔊 TTS · 문자 → 음성

Gemini가 만든 답변은 브라우저의 speechSynthesis 기능으로 읽어줍니다. 따라서 별도의 Python 음성 패키지를 설치하지 않아도 됩니다.

9-1. 음성 대화 흐름

사용자 음성
   ↓
USB 마이크 + arecord 5초 녹음
   ↓
Gemini 음성 받아쓰기(STT)
   ↓
질문 텍스트
   ↓
Gemini 페르소나 응답
   ↓
브라우저 음성 합성(TTS)
   ↓
학생 노트북의 스피커

9-2. 역할별 음성 선택

이번 버전에서는 역할별 자동 선택과 직접 선택 두 가지 방식을 사용할 수 있습니다.

역할자동 음성 느낌
입시 컨설턴트또렷하고 일반적인 설명 속도
아인슈타인 물리 선생님조금 낮고 차분한 설명형
청소년 상담사부드럽고 천천히 말하는 느낌
아리스토텔레스 철학자느리고 낮은 사색형
간달프 마법사가장 느리고 낮은 이야기형
참고: 브라우저 기본 TTS는 실제 캐릭터 성우 음성을 생성하는 방식이 아닙니다. 현재 PC에 설치된 음성을 선택하고 rate와 pitch를 조절해 역할의 느낌을 다르게 만듭니다.
노트북 Chrome/Edge에서 소리가 자동으로 안 날 때: 브라우저는 페이지가 새로 로드된 직후 자동 음성 재생을 차단할 수 있습니다. 이 경우 앱의 🔊 마지막 AI 답변 듣기 버튼을 한 번 누르면 됩니다. 음성 목록이 비어 있어도 기본 시스템 음성으로 재생을 시도합니다.

9-3. 사용 방법

  1. Chrome 또는 Edge에서 http://127.0.0.1:8501을 엽니다.
  2. 🎤 음성으로 말하기 버튼을 누릅니다.
  3. 브라우저 마이크 권한은 필요하지 않습니다. 버튼을 누른 직후 USB 마이크에 5초 동안 말합니다.
  4. 예: “아인슈타인 선생님, 블랙홀을 쉽게 설명해 주세요.”라고 말합니다.
  5. 5초 녹음이 끝나면 WAV 파일이 Gemini로 전송되어 글자로 변환되고 자동으로 질문이 전송됩니다.
  6. AI의 답변이 화면에 표시되고 동시에 학생 노트북의 스피커로 읽힙니다.
  7. 읽기를 멈추려면 ⏹ 음성 중지를 누릅니다.
브라우저 권장: 음성 입력은 브라우저 Web Speech API를 사용하지 않으므로 Raspberry Pi OS Buster의 구형 Chromium에서도 사용할 수 있습니다. 음성 합성은 대부분의 최신 브라우저에서 사용할 수 있습니다.
개인정보 안내: 이번 Raspberry Pi 음성 입력은 녹음한 WAV 파일을 Gemini API에 보내 받아쓰기합니다. 따라서 카메라 이미지는 외부로 보내지 않지만 음성 데이터는 음성인식을 위해 Gemini로 전송됩니다. 수업에서는 주민등록번호·주소·전화번호 같은 개인정보를 말하지 않도록 안내합니다.
이번 업데이트: 음성 입력은 Raspberry Pi 서버가 USB 마이크를 직접 녹음하므로 http://localhost:8501뿐 아니라 같은 네트워크의 다른 PC에서 접속해도 브라우저 마이크 권한 때문에 STT가 차단되지 않습니다.

9. 드롭다운에 없는 확장 역할 프롬프트 5종

위의 드롭다운 5개 역할과 중복되지 않도록 구성했습니다. 아래 프롬프트를 복사해 앱의 직접 만든 역할 프롬프트 칸에 붙여넣으면 새로운 페르소나로 바로 대화할 수 있습니다.

1. 영어 회화 코치

너는 친절한 영어 회화 코치야.
고등학생이 실제 생활에서 쓸 수 있는 짧은 영어 표현을 연습하도록 도와줘.
학생이 한국어로 말하면 자연스러운 영어 표현을 먼저 보여주고, 쉬운 영어로 짧게 대화를 이어가.
문법 오류는 대화를 끊지 말고 필요한 부분만 부드럽게 교정해.
매 답변 끝에는 학생이 영어로 한 문장 대답할 수 있는 질문을 하나 해.

2. 파이썬 코딩 멘토

너는 초보자를 잘 가르치는 파이썬 코딩 멘토야.
고등학생이 코드를 외우기보다 실행 흐름을 이해하도록 설명해.
오류가 발생하면 오류 메시지의 핵심 → 원인 → 수정 방법 → 다시 실행할 명령 순서로 안내해.
한 번에 너무 많은 코드를 주지 말고 작은 단계로 나누어 확인해.
학생이 직접 생각할 수 있도록 마지막에는 짧은 확인 질문을 하나 해.

3. 모의 면접관

너는 학교·동아리·진로 활동을 위한 모의 면접관이야.
한 번에 질문 하나만 하고 학생의 답변을 기다려.
답변을 들은 뒤 강점 한 가지와 개선할 점 한 가지를 짧게 알려주고 다음 질문을 해.
압박하거나 모욕적인 질문은 하지 말고 학생의 경험을 구체적으로 설명하도록 도와줘.
사실을 지어내게 하지 말고 본인의 실제 경험을 바탕으로 답하게 해.

4. 역사 시간여행 안내자

너는 역사 속 현장을 안내하는 시간여행 가이드야.
학생이 시대나 사건을 말하면 그 시기의 장소에 도착한 것처럼 생생하지만 사실에 맞게 설명해.
인물·연도·사건을 지어내지 말고 확실하지 않은 내용은 불확실하다고 밝혀.
설명 뒤에는 그 시대 사람의 입장에서 생각해 볼 질문을 하나 던져줘.
현대의 가치판단만으로 과거 사람을 단순하게 평가하지 않도록 여러 관점을 보여줘.

5. 우주 탐사 미션 지휘관

너는 미래 우주 탐사선의 미션 지휘관이야.
학생을 탐사대원으로 대하고 과학·수학·문제해결 과제를 짧은 미션 형태로 제시해.
미션은 고등학생이 이해할 수 있는 수준으로 만들고 필요한 경우 힌트를 단계별로 줘.
실제 과학 지식과 상상 속 설정을 명확히 구분해.
학생이 선택하면 그 결과에 따라 다음 임무가 이어지는 역할대화를 진행해.
구조 정리: 드롭다운 5개는 바로 선택해서 쓰는 기본 역할, 이 5개는 복사해서 실험하는 확장 역할입니다. 따라서 같은 역할이 반복되지 않습니다.

10. 자주 발생하는 오류 해결

문제 1. 브라우저에 ERR_CONNECTION_REFUSED가 뜸

앱 서버가 꺼져 있을 가능성이 큽니다.

cd ~/camera_persona_app
python3 app.py

문제 2. API 키가 설정되지 않았다고 나옴

현재 터미널에 API 키가 등록되어 있는지 확인합니다.

if [ -n "$GEMINI_API_KEY" ]; then echo "API 키 등록 완료"; else echo "API 키 없음"; fi

API 키 없음이 나오면 5단계의 export GEMINI_API_KEY="..."를 다시 실행한 뒤 앱을 시작합니다.

문제 3. 카메라 관찰 오류

vcgencmd get_camera
raspistill -n -t 1000 -w 640 -h 480 -o test.jpg
ls -lh test.jpg

문제 4. Python 모듈 오류

python3 -c "import flask, requests, cv2; print('OpenCV:', cv2.__version__); print('OK')"

문제 5. apt update 404 오류

Raspbian Buster 저장소가 오래되어 발생할 수 있습니다. 이미 flask, requests, cv2가 OK라면 이번 수업은 그대로 진행합니다. 장기적으로는 Raspberry Pi OS 최신 버전으로 새 SD카드를 준비하는 것을 추천합니다.

문제 6. API 키가 화면에 노출됨

  1. 노출된 API 키는 더 이상 사용하지 않습니다.
  2. 새 수업용 API 키로 교체합니다.
  3. 키를 코드나 ~/.bashrc에 영구 저장하지 않습니다.
  4. API 키가 보이는 화면은 캡처하거나 공유하지 않습니다.

문제 7. OpenCV detectMultiScale !empty() 오류

얼굴·웃음·안경용 Haar XML 파일이 없거나 읽히지 않을 때 발생합니다. 이번 업데이트본은 CascadeClassifier.empty()를 먼저 확인해 앱 전체가 중단되지 않도록 수정했습니다.

문제 8. 안경 또는 웃음 분석만 나오지 않음

해당 Haar XML 파일이 없는 경우입니다. 얼굴 감지만 정상이라면 앱과 Gemini 대화는 계속 사용할 수 있습니다.

문제 9. 음성 질문 버튼을 눌렀는데 USB 마이크 녹음 오류가 나옴

터미널에서 arecord -l로 마이크 카드 번호를 확인합니다. 현재 장비는 card 2, device 0이므로 기본값은 plughw:2,0입니다. 번호가 다르면 export PERSONA_MIC_DEVICE="plughw:카드번호,장치번호"로 바꿉니다.

문제 10. 원하는 역할별 목소리가 없음

TTS 음성 목록은 학생 노트북과 Chrome/Edge에 설치된 음성에 따라 달라집니다. 자동 선택 대신 직접 선택을 사용할 수도 있습니다.

문제 11. Gemini API 모델 오류

API 키가 정상인데 모델 관련 오류가 나오면 현재 프로젝트에서 사용할 수 있는 모델명을 확인한 뒤 GEMINI_MODEL 환경변수로 바꿀 수 있습니다.

export GEMINI_MODEL="수업에서_사용할_모델명"
python3 app.py

문제 12. 예전 버전에서 “음성 인식 오류: network”가 나옴

이 오류는 Buster Chromium의 Web Speech API에서 발생할 수 있습니다. 이번 업데이트본은 브라우저 SpeechRecognition을 사용하지 않고 arecord → WAV → Gemini 음성 받아쓰기 방식으로 변경했으므로 Chromium의 마이크 권한이나 Web Speech network 오류에 의존하지 않습니다.

문제 13. 역할별 목소리 목록이 비어 있음

VNC 안의 Raspberry Pi Chromium에서 실행하지 말고 학생 노트북의 최신 Chrome/Edge에서 http://라즈베리파이IP:8501로 접속합니다.

문제 14. AI 답변은 보이지만 소리가 안 남

노트북 스피커 볼륨과 브라우저 탭 음소거를 확인합니다. Raspberry Pi 쪽에 스피커가 없어도 됩니다.

문제 15. 카메라에서 얼굴을 잘 못 찾음

카메라를 정면으로 바라보고 얼굴이 화면의 약 1/4 이상 보이도록 가까이 옵니다. 역광을 피하고 얼굴 앞쪽을 밝게 합니다.

문제 16. 노트북에서 AI 답변은 보이는데 소리가 안 들림

브라우저의 자동재생 정책 때문에 페이지가 새로 열린 직후 TTS가 차단될 수 있습니다. 앱에서 🔊 마지막 AI 답변 듣기를 직접 클릭합니다. 이 방식은 사용자 클릭으로 시작되므로 자동 읽기보다 안정적입니다.

그래도 들리지 않으면 Windows의 스피커 출력 장치, 볼륨, Chrome/Edge 탭 음소거를 확인합니다.

문제 17. Console에 Invalid regular expression 오류가 나타남

구형 코드에서 Python의 일반 삼중따옴표 문자열 안에 JavaScript 정규식을 넣으면서 \\가 \로 줄어들어 브라우저 정규식 문법 오류가 발생할 수 있었습니다. 이번 버전은 Flask의 HTML 문자열을 r"""...""" 형태의 raw string으로 바꾸어 정규식의 백슬래시가 그대로 브라우저에 전달되도록 수정했습니다.

정상 동작 시 Chrome/Edge Console에서 speechSynthesis.getVoices()를 실행했을 때 음성 목록이 반환되고, 앱의 사용할 목소리 드롭다운에도 음성이 표시됩니다.

문제 18. 자동 감지된 마이크가 잘못된 장치임

여러 USB 오디오 장치가 동시에 연결된 경우 첫 번째 USB Capture 장치를 선택할 수 있습니다. 터미널에서 arecord -l을 확인한 뒤 해당 학생만 수동 지정합니다.

export PERSONA_MIC_DEVICE="plughw:카드번호,장치번호"
python3 app.py

문제 19. 앱 화면에 “USB 마이크를 찾지 못했습니다”가 표시됨

마이크를 다시 연결하고 arecord -l에서 Capture 장치가 나타나는지 확인합니다. 장치가 보이면 앱을 다시 실행합니다.

문제 20. NameError: MIC_DEVICE is not defined

이전 자동감지 버전에서는 voice_status가 MIC_DEVICE, MIC_LABEL = detect_microphone_device()보다 먼저 실행되는 순서 오류가 있었습니다. 이번 수정본에서는 반드시 마이크 자동 감지 함수 실행 후 voice_status를 설정하도록 순서를 바로잡았습니다.

따라서 학생은 별도 마이크 번호 설정 없이 python3 app.py만 실행하면 됩니다.

문제 21. 녹음 버튼을 누를 때 “음성 출력 오류: interrupted”가 표시됨

AI 답변을 노트북에서 읽고 있는 중에 5초 음성 녹음 버튼을 누르면 브라우저가 기존 TTS를 중단하면서 interrupted 이벤트를 발생시킬 수 있습니다. 이것은 실제 오류가 아닙니다.

이번 수정본은 녹음 버튼을 누르는 순간 노트북 TTS를 먼저 중지하고, interrupted와 canceled는 오류 메시지로 표시하지 않습니다. 따라서 노트북 스피커의 AI 음성이 Raspberry Pi 마이크에 다시 녹음되는 현상도 줄어듭니다.

문제 22. API 키 삭제 버튼에서 “Unexpected token < ... is not valid JSON” 오류가 나옴

구형 Flask에서는 Python 딕셔너리를 그대로 반환할 때 JSON 응답으로 처리되지 않아 HTML 오류 페이지가 반환될 수 있습니다. 브라우저가 그 HTML을 JSON으로 읽으면서 발생하는 오류입니다.

이번 수정본은 jsonify()를 사용해 구형 Flask에서도 항상 정상적인 JSON을 반환하도록 수정했습니다. 수정한 app.py를 저장한 뒤 기존 서버를 Ctrl + C로 종료하고 다시 실행하세요.

문제 23. 5초 음성 질문 녹음을 눌렀는데 AI 답변 음성이 재생됨

이전 버전에서는 음성 녹음이 끝나자마자 Gemini 답변까지 생성하고, 페이지가 다시 열리면서 자동 읽기 기능이 기존 또는 새 AI 답변을 바로 읽을 수 있었습니다.

이번 수정본에서는 5초 음성 질문 녹음 = 녹음 + 음성→텍스트 변환까지만 수행합니다. 변환된 문장은 역할대화 입력칸에 들어가며, 학생이 내용을 확인한 뒤 페르소나와 대화하기를 눌러야 AI 답변이 생성됩니다.

문제 24. 라즈베리파이를 바꾸면 USB 마이크 카드 번호가 달라짐

Raspberry Pi마다 USB 오디오 장치가 card 1, card 2, card 3처럼 다르게 잡힐 수 있습니다.

이번 수정본은 앱 시작 시 arecord -l 결과를 자동 분석하여 USB PnP Sound Device, USB Audio, Audio CODEC, C-Media, PCM2902 등의 녹음 장치를 우선 선택합니다. USB 장치를 못 찾으면 첫 번째 녹음 장치를 사용합니다.

특수한 장치를 꼭 지정해야 할 때만 export PERSONA_MIC_DEVICE="plughw:2,0"처럼 수동 지정하면 됩니다.

문제 25. NameError: name 'voice_status' is not defined

범용 USB 마이크 자동감지 함수를 교체하면서 앱 시작 시 실행해야 하는 MIC_DEVICE, MIC_LABEL = detect_microphone_device()와 voice_status 초기화 코드가 누락되면 첫 화면에서 Flask 500 오류가 발생합니다.

이번 수정본은 앱 시작 시 마이크를 자동 감지한 뒤 voice_status를 항상 먼저 초기화하도록 수정했습니다.

11. 참고 공식 자료

11. 최종 실행 · 역할대화 · 수업 종료

모든 제작 과정이 끝났습니다. 이제 앱을 실행하여 카메라, USB 마이크, Gemini 역할대화, 노트북 음성 출력을 실제로 확인합니다.

11-1. 앱 최종 실행하기

라즈베리파이 터미널에서 다음 명령을 실행합니다.

cd ~/camera_persona_app
python3 app.py

터미널에 다음과 비슷한 주소가 나타나면 Flask 서버가 정상적으로 실행된 것입니다.

Running on http://0.0.0.0:8501

노트북 브라우저에서 라즈베리파이의 호스트 이름이나 IP 주소를 이용해 접속합니다.

http://라즈베리파이주소:8501

11-2. AI 페르소나 역할대화 실습하기

  1. 페르소나 역할을 선택합니다.
  2. 카메라로 현재 화면의 특징을 확인합니다.
  3. 질문을 직접 입력하거나 🎤 5초 음성 질문 녹음을 사용합니다.
  4. 페르소나와 대화하기를 눌러 AI 답변을 확인합니다.
  5. 필요하면 🔊 마지막 AI 답변 듣기로 노트북에서 답변을 다시 듣습니다.

11-3. 수업 종료 · API 키 삭제하기

실습이 모두 끝난 뒤 앱의 음성 제어 영역에서 🔐 수업 종료 · API 키 삭제 버튼을 누릅니다.

버튼이 ✅ API 키 삭제 완료로 바뀌면 현재 Flask 앱 메모리에 있던 API 키가 삭제된 것입니다.

11-4. Flask 서버 종료하기

라즈베리파이 터미널로 돌아가 다음 키를 눌러 서버를 종료합니다.

Ctrl + C

11-5. 현재 터미널의 API 키까지 삭제하기

현재 터미널 환경변수에 남아 있는 API 키도 삭제합니다.

unset GEMINI_API_KEY

API 키가 삭제되었는지 확인합니다. 키 자체는 화면에 출력하지 않습니다.

if [ -z "$GEMINI_API_KEY" ]; then echo "API 키 삭제 완료"; else echo "API 키가 남아 있음"; fi

정상 결과:

API 키 삭제 완료
Raspberry Pi를 재부팅하거나 현재 터미널 세션을 종료해도 이번 방식으로 등록한 API 키 환경변수는 유지되지 않습니다.