🎤 음성 주문 키오스크 — 전체 코드 (ver 0.2)
voice-demo-code-ver0.2.py · pygame + speech_recognition + pyttsx3 (STT/TTS + 레벤슈타인)
ver 0.2에서 바뀐 점
① 버거 3종으로 확대(치즈버거 추가)   ② 음성 주문 때는 가운데 3행 1열 큰 박스로 표시   ③ 글씨 크게(메뉴 46pt)   ④ 반복되던 주문 코드를 ask_by_voice() 함수로 정리   ⑤ 메뉴마다 실제 음식 사진 표시
ver 0.1 파일은 지우지 않았습니다. 두 코드를 비교해보세요.
ver 0.2 메뉴 사진 9장 다운로드 버거 3종 · 사이드 3종 · 음료 3종과 출처 안내가 들어 있습니다.
다운로드한 ZIP 파일의 압축을 풀어, .py 파일과 같은 폴더menu_images 폴더를 두세요. (사진이 없어도 프로그램은 실행됩니다 — 글씨만 나옵니다)
사진은 브랜드 공식 제품 이미지이며, 출처와 이용 조건 안내는 폴더 안 CREDITS.md에 있습니다.
실행하려면 pip install pygame SpeechRecognition pyaudio pyttsx3 후 이 코드를 .py 파일로 저장해서 실행하세요. 코드는 voice-demo-code-ver0.2.py가 수정될 때마다 이 페이지에도 그대로 반영됩니다.
"""시니어 음성 주문 키오스크 — ver 0.2

ver 0.1(voice-demo-code.py)에서 바뀐 점
--------------------------------------
1. 버거를 3종으로 늘렸다 (치킨버거 / 불고기버거 / 치즈버거)
2. 음성으로 주문받을 때는 '시니어 화면'을 쓴다
   - 메뉴를 가로로 늘어놓지 않고, 화면 가운데에 3행 1열로 크게 보여준다
   - 버튼 박스를 훨씬 크게(620x120) 키웠다
3. 글씨를 크게 했다 (메뉴 46pt, 안내 문구 38pt)
4. 버거/사이드/음료를 물어보는 코드가 세 번 반복되던 것을
   ask_by_voice() 함수 하나로 정리했다
5. 메뉴마다 실제 음식 사진(PNG)을 넣었다
   - 글씨만 있을 때보다 어르신이 훨씬 빨리 알아본다
   - 사진은 menu_images 폴더에 '메뉴이름.png' 로 넣어두면 자동으로 불러온다
   - 사진이 없어도 프로그램은 그대로 돌아간다 (글씨만 표시)

실행하려면 이 파일과 같은 폴더에 menu_images 폴더가 있어야 한다.
    voice-demo-code-ver0.2.py
    menu_images/
        치킨버거.png, 불고기버거.png, 치즈버거.png,
        감자튀김.png, 어니언링.png, 치즈스틱.png,
        콜라.png, 사이다.png, 환타.png
사진 출처와 라이선스는 menu_images/CREDITS.md 에 적어두었다.

ver 0.1 파일은 지우지 않았다. 두 파일을 나란히 놓고 비교해보면
"무엇을 왜 바꿨는지"를 그대로 확인할 수 있다.
"""

import os
from collections import deque

import pygame
import speech_recognition as sr
import pyttsx3

# ---------- 기본 설정값 ----------
W, H = 1000, 650
BG, INK, SUB = (253, 248, 240), (26, 16, 8), (120, 100, 80)
CARD, LINE = (255, 255, 255), (176, 96, 16)
SELECTED = (255, 224, 150)   # 선택된 메뉴 버튼 색
DONE_GREEN = (10, 122, 48)
REC_RED = (200, 40, 40)      # "듣고 있어요" 표시 색

# [변경 1] 치즈버거를 추가해서 버거도 3종이 되었다.
#          사이드·음료와 개수가 같아져서, 아래 시니어 화면(3행 1열)을 모두 똑같이 쓸 수 있다.
BURGER_MENU = ["치킨버거", "불고기버거", "치즈버거"]
SIDE_MENU = ["감자튀김", "어니언링", "치즈스틱"]
DRINK_MENU = ["콜라", "사이다", "환타"]

AUTO_LISTEN_MS = 10000   # 이 시간(1000분의 1초) 동안 터치가 없으면 음성 주문 시작

# [변경 2] 시니어 화면에서 쓸 큰 박스 크기
SENIOR_BOX_W, SENIOR_BOX_H = 620, 120
SENIOR_GAP = 26          # 박스 사이 간격

# [변경 5] 메뉴 사진을 넣을 폴더 (이 파이썬 파일과 같은 위치에 menu_images 폴더를 둔다)
IMAGE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "menu_images")


# ---------- 레벤슈타인 거리로 메뉴 이름 맞추기 ----------
def levenshtein(a, b):
    n, m = len(a), len(b)
    table = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        table[i][0] = i
    for j in range(m + 1):
        table[0][j] = j
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            cost = 0 if a[i - 1] == b[j - 1] else 1
            table[i][j] = min(
                table[i - 1][j] + 1,
                table[i][j - 1] + 1,
                table[i - 1][j - 1] + cost
            )
    return table[n][m]


def find_best_menu(text, menu_list):
    """text와 가장 비슷한 메뉴 이름과, 그때의 편집 거리를 돌려준다."""
    best_name = None
    best_dist = None
    for name in menu_list:
        d = levenshtein(text, name)
        if best_dist is None or d < best_dist:
            best_dist = d
            best_name = name
    return best_name, best_dist


# ---------- 음성 인식 ----------
recognizer = sr.Recognizer()


def listen_and_recognize():
    """마이크로 한 번 듣고, 알아들은 문장을 돌려준다. 못 들으면 빈 문자열."""
    with sr.Microphone() as source:
        recognizer.adjust_for_ambient_noise(source, duration=0.6)   # 주변 소음 줄이기
        try:
            audio = recognizer.listen(source, timeout=5, phrase_time_limit=6)
        except sr.WaitTimeoutError:
            return ""
    try:
        return recognizer.recognize_google(audio, language="ko-KR")
    except sr.UnknownValueError:
        return ""
    except sr.RequestError:
        return ""


# ---------- TTS(글자를 소리로 읽어주기) ----------
def speak(text):
    engine = pyttsx3.init()   # 매번 새로 만들어야 두 번째 이후에도 소리가 잘 난다
    engine.say(text)
    engine.runAndWait()
    engine.stop()


# ---------- 화면 준비 ----------
pygame.init()
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("시니어 음성 주문 키오스크 ver 0.2")
font = pygame.font.SysFont("malgungothic", 26)
small_font = pygame.font.SysFont("malgungothic", 18)
big_font = pygame.font.SysFont("malgungothic", 34)

# [변경 3] 시니어 화면 전용 큰 글꼴
senior_font = pygame.font.SysFont("malgungothic", 46, bold=True)       # 메뉴 이름
senior_head_font = pygame.font.SysFont("malgungothic", 38, bold=True)  # 위쪽 안내 문구
clock = pygame.time.Clock()


# ---------- [변경 5] 메뉴 사진 불러오기 ----------
# 큰 원본은 메뉴 표시용 크기로 줄여 한 번만 처리한다.
source_images = {}


def prepare_menu_image(path):
    picture = pygame.image.load(path).convert_alpha()
    width, height = picture.get_size()
    ratio = min(1, 384 / max(width, height))
    if ratio < 1:
        picture = pygame.transform.scale(
            picture, (max(1, round(width * ratio)), max(1, round(height * ratio))))
    width, height = picture.get_size()

    # 가장자리와 연결된 흰 배경만 투명하게 바꾼다.
    # 원본 전체 크기의 연결 영역 마스크를 여러 장 만드는 대신 작은 사진을 처리한다.
    pending = deque((x, y) for x in range(width) for y in (0, height - 1))
    pending.extend((x, y) for y in range(height) for x in (0, width - 1))
    while pending:
        x, y = pending.popleft()
        r, g, b, alpha = picture.get_at((x, y))
        if alpha != 255 or min(r, g, b) < 244:
            continue
        picture.set_at((x, y), (0, 0, 0, 0))
        for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
            if 0 <= nx < width and 0 <= ny < height:
                pending.append((nx, ny))
    return picture


def load_menu_images(size, background=CARD):
    """menu_images 폴더에서 '메뉴이름.png'를 찾아 size 크기로 불러온다.

    사진이 없어도 프로그램이 멈추지 않도록, 없는 메뉴는 그냥 건너뛴다.
    (사진 없이 글씨만 나오게 된다)
    """
    images = {}
    for name in BURGER_MENU + SIDE_MENU + DRINK_MENU:
        path = os.path.join(IMAGE_DIR, f"{name}.png")
        if os.path.exists(path):
            if path not in source_images:
                try:
                    source_images[path] = prepare_menu_image(path)
                except (pygame.error, OSError, ValueError) as error:
                    print(f"사진을 읽지 못했습니다: {name} ({error})")
                    source_images[path] = None
            picture = source_images[path]
            if picture is None:
                continue  # 손상된 사진은 글씨만 표시하고 나머지 메뉴는 계속 불러온다.
            pygame.event.pump()
            # 실제 표시할 배경에 먼저 합성하면 투명 영역의 색 번짐을 막는다.
            clean = pygame.Surface(picture.get_size()).convert()
            clean.fill(background)
            clean.blit(picture, (0, 0))

            # 가로·세로에 같은 배율을 적용해서 음식 모양을 유지한다.
            width, height = clean.get_size()
            ratio = min(size / width, size / height)
            fitted = pygame.transform.smoothscale(
                clean, (max(1, round(width * ratio)), max(1, round(height * ratio))))
            tile = pygame.Surface((size, size)).convert()
            tile.fill(background)
            tile.blit(fitted, fitted.get_rect(center=(size // 2, size // 2)))
            images[name] = tile
    return images


# 사진을 준비하는 동안 검은 창 대신 안내를 표시한다.
screen.fill(BG)
loading = font.render("메뉴 사진을 준비하고 있어요", True, INK)
screen.blit(loading, loading.get_rect(center=(W // 2, H // 2)))
pygame.display.flip()

# 일반·선택·장바구니 배경에 맞춘 사진을 미리 만들어 둔다.
# 그릴 때마다 크기를 바꾸면 느려지므로, 시작할 때 한 번만 만든다.
small_images = load_menu_images(78)
small_selected_images = load_menu_images(78, SELECTED)
senior_images = load_menu_images(96)
senior_selected_images = load_menu_images(96, SELECTED)
cart_images = load_menu_images(96, BG)

if not small_images:
    print("menu_images 폴더에 사진이 없어서 글씨만 표시합니다.")

cart = {"버거": None, "사이드": None, "음료": None}
state = "menu"   # "menu"(터치로 고르기) -> "cart"(장바구니) -> "done"(결제 완료)
last_interaction = pygame.time.get_ticks()   # 마지막으로 화면을 건드린 시각(ms)

# ---------- 터치 모드용 메뉴 버튼 (가로로 나열) ----------
burger_buttons = []
for i, name in enumerate(BURGER_MENU):
    burger_buttons.append({"name": name, "rect": pygame.Rect(100 + i * 220, 140, 200, 100)})

side_buttons = []
for i, name in enumerate(SIDE_MENU):
    side_buttons.append({"name": name, "rect": pygame.Rect(100 + i * 220, 280, 200, 100)})

drink_buttons = []
for i, name in enumerate(DRINK_MENU):
    drink_buttons.append({"name": name, "rect": pygame.Rect(100 + i * 220, 420, 200, 100)})

pay_btn = pygame.Rect(W // 2 - 160, H - 110, 320, 80)      # 장바구니 화면의 "카드 넣기" 버튼
restart_btn = pygame.Rect(W // 2 - 160, H - 110, 320, 80)  # 완료 화면의 "처음으로" 버튼


# ---------- 터치 모드 화면 그리기 (ver 0.1과 동일) ----------
def draw_buttons(button_list, cart_key):
    """버튼 목록을 하나씩 그린다. 이미 고른 메뉴는 노란색으로 표시.

    [변경 5] 사진이 있으면 왼쪽에 사진, 오른쪽에 이름을 그린다.
    """
    for b in button_list:
        color = SELECTED if cart[cart_key] == b["name"] else CARD
        pygame.draw.rect(screen, color, b["rect"], border_radius=12)
        pygame.draw.rect(screen, LINE, b["rect"], 3, border_radius=12)

        pictures = small_selected_images if cart[cart_key] == b["name"] else small_images
        picture = pictures.get(b["name"])
        if picture:
            pic_rect = picture.get_rect(midleft=(b["rect"].x + 10, b["rect"].centery))
            screen.blit(picture, pic_rect)
            # 사진을 뺀 나머지 자리에 이름을 쓴다.
            # 이름이 길어 자리에 안 들어가면 작은 글꼴로 바꾼다 (예: "불고기버거")
            space = b["rect"].right - pic_rect.right - 12
            name_font = font if font.size(b["name"])[0] <= space else small_font
            label = name_font.render(b["name"], True, INK)
            screen.blit(label, label.get_rect(
                center=(pic_rect.right + (b["rect"].right - pic_rect.right) // 2, b["rect"].centery)))
        else:
            label = font.render(b["name"], True, INK)
            screen.blit(label, label.get_rect(center=b["rect"].center))


def draw_menu_screen():
    """터치 모드 기본 화면: 버거/사이드/음료를 한 번에 다 보여준다."""
    screen.blit(font.render("버거", True, INK), (60, 100))
    draw_buttons(burger_buttons, "버거")

    screen.blit(font.render("사이드", True, INK), (60, 240))
    draw_buttons(side_buttons, "사이드")

    screen.blit(font.render("음료", True, INK), (60, 380))
    draw_buttons(drink_buttons, "음료")

    seconds_left = max(0, AUTO_LISTEN_MS // 1000 - (pygame.time.get_ticks() - last_interaction) // 1000)
    timer_text = small_font.render(f"남은 시간 {seconds_left}초 (터치가 없으면 음성 안내가 시작돼요)", True, SUB)
    screen.blit(timer_text, (60, 30))


# ---------- [변경 2·3] 시니어 화면: 3행 1열 + 큰 박스 + 큰 글씨 ----------
def senior_buttons(menu_list):
    """메뉴를 화면 한가운데에 세로로 3개 쌓는다. (3행 1열)"""
    buttons = []
    count = len(menu_list)
    total_h = count * SENIOR_BOX_H + (count - 1) * SENIOR_GAP   # 전체 높이
    top = (H - total_h) // 2 + 40          # 세로 가운데, 위 안내 문구 자리만큼 조금 내림
    x = (W - SENIOR_BOX_W) // 2            # 가로 가운데
    for i, name in enumerate(menu_list):
        y = top + i * (SENIOR_BOX_H + SENIOR_GAP)
        buttons.append({"name": name, "rect": pygame.Rect(x, y, SENIOR_BOX_W, SENIOR_BOX_H)})
    return buttons


def draw_senior_screen(menu_list, cart_key, guide_text, guide_color):
    """시니어용 화면을 통째로 그리고 바로 화면에 반영한다."""
    screen.fill(BG)

    head = senior_head_font.render(guide_text, True, guide_color)
    screen.blit(head, head.get_rect(center=(W // 2, 70)))

    for b in senior_buttons(menu_list):
        color = SELECTED if cart[cart_key] == b["name"] else CARD
        pygame.draw.rect(screen, color, b["rect"], border_radius=20)   # 큰 박스
        pygame.draw.rect(screen, LINE, b["rect"], 5, border_radius=20)  # 테두리도 두껍게

        # [변경 5] 사진이 있으면 왼쪽에 크게 넣고, 이름은 남은 자리 가운데에 쓴다
        pictures = senior_selected_images if cart[cart_key] == b["name"] else senior_images
        picture = pictures.get(b["name"])
        if picture:
            pic_rect = picture.get_rect(midleft=(b["rect"].x + 16, b["rect"].centery))
            screen.blit(picture, pic_rect)
            label = senior_font.render(b["name"], True, INK)
            screen.blit(label, label.get_rect(
                center=(pic_rect.right + (b["rect"].right - pic_rect.right) // 2, b["rect"].centery)))
        else:
            label = senior_font.render(b["name"], True, INK)           # 큰 글씨
            screen.blit(label, label.get_rect(center=b["rect"].center))

    pygame.display.flip()


# ---------- 장바구니 / 완료 화면 (글씨를 키웠다) ----------
def draw_cart_screen():
    title = senior_head_font.render("주문하신 내용", True, INK)
    screen.blit(title, title.get_rect(center=(W // 2, 80)))

    # [변경 5] 고른 메뉴를 사진과 함께 보여준다 (글자만 있을 때보다 확인하기 쉽다)
    for i, key in enumerate(["버거", "사이드", "음료"]):
        y = 190 + i * 100
        picture = cart_images.get(cart[key])
        if picture:
            screen.blit(picture, picture.get_rect(center=(W // 2 - 190, y)))
        label = senior_font.render(f"{key} : {cart[key]}", True, INK)
        screen.blit(label, label.get_rect(midleft=(W // 2 - 120, y)))

    pygame.draw.rect(screen, DONE_GREEN, pay_btn, border_radius=16)
    label = senior_head_font.render("카드를 넣어주세요", True, (255, 255, 255))
    screen.blit(label, label.get_rect(center=pay_btn.center))


def draw_done_screen():
    title = senior_head_font.render("주문이 완료됐습니다", True, DONE_GREEN)
    screen.blit(title, title.get_rect(center=(W // 2, H // 2 - 80)))
    thanks = senior_font.render("감사합니다!", True, DONE_GREEN)
    screen.blit(thanks, thanks.get_rect(center=(W // 2, H // 2)))

    pygame.draw.rect(screen, LINE, restart_btn, border_radius=16)
    label = senior_head_font.render("처음으로", True, (255, 255, 255))
    screen.blit(label, label.get_rect(center=restart_btn.center))


# ---------- [변경 4] 한 종류를 음성으로 물어보는 과정을 함수 하나로 ----------
def ask_by_voice(cart_key, menu_list, first_speech):
    """cart_key(버거/사이드/음료) 하나를 음성으로 주문받아 cart에 넣는다.

    ver 0.1에서는 이 과정이 세 번 거의 똑같이 반복되어 있었다.
    함수로 묶으면 문구만 바꿔서 세 번 부르면 되고, 화면 모양을 고칠 때도 여기 한 곳만 고치면 된다.
    """
    # 1) 무엇을 고르는 순서인지 큰 화면으로 보여주고 안내 음성을 낸다
    draw_senior_screen(menu_list, cart_key, f"{cart_key}를 골라주세요", SUB)
    speak(first_speech)

    # 2) 듣고 있다고 표시한 뒤 실제로 듣는다
    draw_senior_screen(menu_list, cart_key, "듣고 있어요...", REC_RED)
    heard = listen_and_recognize()
    name, dist = find_best_menu(heard, menu_list) if heard else (None, None)

    # 3) 못 알아들었으면 한 번 더 기회를 준다
    if not name:
        speak("다시 한 번 말씀해주세요")
        draw_senior_screen(menu_list, cart_key, "다시 말씀해주세요", REC_RED)
        heard = listen_and_recognize()
        name, dist = find_best_menu(heard, menu_list) if heard else (None, None)

    # 4) 그래도 못 들으면 첫 번째 메뉴로 정한다
    cart[cart_key] = name if name else menu_list[0]

    # 5) 고른 메뉴를 큰 글씨로 확인시켜준다 (고른 칸은 노란색으로 보인다)
    draw_senior_screen(menu_list, cart_key, f"{cart[cart_key]} 맞으시죠?", DONE_GREEN)
    speak(f"{cart[cart_key]} 선택하셨습니다")
    pygame.time.delay(800)


# ---------- 음성으로 주문받기 (10초 동안 터치가 없으면 실행) ----------
def voice_order():
    global state

    ask_by_voice("버거", BURGER_MENU, "먼저 버거를 골라주세요. 드시고 싶은 버거를 말씀해주세요.")
    ask_by_voice("사이드", SIDE_MENU, "이번엔 사이드를 골라주세요. 드시고 싶은 사이드를 말씀해주세요.")
    ask_by_voice("음료", DRINK_MENU, "마지막으로 음료를 골라주세요. 드시고 싶은 음료를 말씀해주세요.")

    # 다 골랐으니 장바구니를 보여주고 카드 넣으라고 안내
    state = "cart"
    screen.fill(BG)
    draw_cart_screen()
    pygame.display.flip()
    speak("카드를 넣어주세요")


# ---------- 메인 루프 ----------
running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

        elif state == "menu" and event.type == pygame.MOUSEBUTTONDOWN:
            for b in burger_buttons:
                if b["rect"].collidepoint(event.pos):
                    cart["버거"] = b["name"]
                    last_interaction = pygame.time.get_ticks()   # 터치했으니 다시 10초 카운트

            for b in side_buttons:
                if b["rect"].collidepoint(event.pos):
                    cart["사이드"] = b["name"]
                    last_interaction = pygame.time.get_ticks()

            for b in drink_buttons:
                if b["rect"].collidepoint(event.pos):
                    cart["음료"] = b["name"]
                    last_interaction = pygame.time.get_ticks()

            if cart["버거"] and cart["사이드"] and cart["음료"]:   # 세 가지 다 골랐으면
                state = "cart"                                     # 자동으로 장바구니 -> 결제 화면

        elif state == "cart" and event.type == pygame.MOUSEBUTTONDOWN:
            if pay_btn.collidepoint(event.pos):
                state = "done"

        elif state == "done" and event.type == pygame.MOUSEBUTTONDOWN:
            if restart_btn.collidepoint(event.pos):
                cart = {"버거": None, "사이드": None, "음료": None}
                state = "menu"
                last_interaction = pygame.time.get_ticks()

    # 10초 동안 터치가 없으면 음성 주문 시작 (menu 화면에서만 확인)
    if state == "menu" and pygame.time.get_ticks() - last_interaction >= AUTO_LISTEN_MS:
        voice_order()

    screen.fill(BG)
    if state == "menu":
        draw_menu_screen()
    elif state == "cart":
        draw_cart_screen()
    elif state == "done":
        draw_done_screen()

    pygame.display.flip()
    clock.tick(60)

pygame.quit()