아침에 알람이 울리긴 하는데…
“몇 시인지 말로 알려주면 진짜 바로 일어나겠는데?” 싶을 때가 있죠.

이번 글에서는 오렌지파이5 + Ubuntu 환경에서, 파이썬으로 말하는 음성 알람을 만드는 방법을 정리했습니다.

  • 06:00부터 10분 단위로 06:30까지
  • “주인님 일어나세요. 현재 시간 06시 10분입니다.” 같은 문장을 TTS로 말해주고
  • 설정파일 1개로 매일/평일/1회 + 공휴일 제외까지 제어하고
  • EdgeTTS 캐시(재생 빠름) + 중복 재생 방지(안전) + **systemd 자동 실행(운영 편함)**까지 묶었습니다.

목표 동작 요약

  • 알람 시간: 06:00, 06:10, 06:20, 06:30
  • 출력: 스피커로 음성 재생(mp3)
  • 스케줄 방식: 설정파일(JSON) 기반
  • 운영 안정성:
    • 같은 분에 두 번 울리는 것 방지(상태파일 기록)
    • TTS는 캐시(mp3 재사용)로 속도/안정성 개선
    • systemd로 부팅 후 자동 실행

준비물

  • Orange Pi 5 (또는 Ubuntu 머신)
  • Ubuntu 22.04/24.04 계열
  • 스피커(3.5mm/USB/블루투스 등)

설치(필수 패키지)

sudo apt update
sudo apt install -y python3-pip mpg123
pip3 install edge-tts holidays
  • edge-tts : 텍스트 → 음성(mp3) 생성
  • mpg123 : mp3를 바로 재생(가볍고 안정적)
  • holidays : 한국 공휴일 제외(선택처럼 보이지만 “휴일 제외”를 쓰려면 필요)

1) 설정파일 1개로 알람 규칙 관리하기

프로젝트 폴더를 만들고, 설정파일을 준비합니다.

mkdir -p ~/edge_alarm
cd ~/edge_alarm
nano alarm_config.json

alarm_config.json

{
  "mode": "weekdays",
  "times": ["06:00", "06:10", "06:20", "06:30"],
  "message_template": "주인님 일어나세요. 현재 시간 {hh}시 {mm}분입니다.",
  "voice": "ko-KR-SunHiNeural",
  "rate": "+0%",
  "volume": 100,
  "exclude_public_holidays": true,
  "country_holidays": "KR",
  "once_date": "2026-02-12"
}

핵심 옵션 설명

  • mode
    • daily : 매일
    • weekdays : 평일만(토/일 제외)
    • once : 특정 날짜 once_date에만 1회 실행
  • times : 울릴 시간을 배열로 관리
  • message_template : {hh}, {mm}가 현재 시각으로 자동 치환
  • exclude_public_holidays : 공휴일 제외 여부
  • country_holidays : 한국은 "KR"

2) 파이썬 실행 코드(alarm_tts.py)

이 코드는 아래 3가지를 “운영 가능한 수준”으로 묶는 게 포인트입니다.

  1. EdgeTTS 캐시: 같은 문장은 mp3를 저장해 재사용
  2. 중복방지: YYYY-MM-DD_HH:MM 키로 “이미 울림” 기록
  3. 자동실행: systemd로 부팅 시 자동 기동

아래 파일을 저장하세요.

nano alarm_tts.py
chmod +x alarm_tts.py

alarm_tts.py (한글 상세 주석 포함)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
[EdgeTTS 음성 알람 스크립트]
- 설정 파일(alarm_config.json) 하나만 수정해서 운영 가능
- EdgeTTS로 MP3 생성 후 스피커로 재생(mpg123 사용)
- MP3 캐시 저장(같은 문장 재사용) -> 빠르고 안정적
- 상태 파일 기록(같은 분 중복 재생 방지)
- systemd 서비스로 등록하면 부팅 후 자동 실행 가능
"""

import asyncio
import json
import os
import sys
import time
import hashlib
import subprocess
from datetime import datetime, date, timedelta

# EdgeTTS 라이브러리 import
try:
    import edge_tts
except ImportError:
    print("edge-tts가 설치되어 있지 않습니다. `pip3 install edge-tts`를 실행하세요.")
    sys.exit(1)

# 공휴일 제외 기능을 위한 라이브러리(없으면 공휴일 판단 기능이 비활성)
try:
    import holidays as holidays_lib
except ImportError:
    holidays_lib = None

# -----------------------------
# 파일 경로(환경변수로 오버라이드 가능)
# -----------------------------
CONFIG_PATH = os.environ.get("ALARM_CONFIG", "./alarm_config.json")  # 설정 파일
STATE_PATH  = os.environ.get("ALARM_STATE", "./alarm_state.json")    # 중복방지 상태 파일
CACHE_DIR   = os.environ.get("ALARM_CACHE", "./tts_cache")           # TTS mp3 캐시 폴더


def load_json(path: str, default):
    """JSON 파일 로딩. 파일이 없으면 default 반환"""
    if not os.path.exists(path):
        return default
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def save_json(path: str, obj):
    """
    JSON 저장을 안전하게 하기 위한 방식
    - 임시 파일(.tmp)에 먼저 저장한 뒤 os.replace로 교체
    - 저장 중 전원 문제 등으로 파일이 깨질 위험을 줄임
    """
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(obj, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def ensure_dir(p: str):
    """폴더가 없으면 생성"""
    os.makedirs(p, exist_ok=True)


def is_public_holiday(d: date, country_code: str) -> bool:
    """
    특정 국가 공휴일 여부 판단
    - holidays 라이브러리가 없으면 False 처리(공휴일 제외 비활성)
    """
    if holidays_lib is None:
        return False

    try:
        h = holidays_lib.country_holidays(country_code)
        return d in h
    except Exception:
        return False


def should_run_today(cfg: dict, today: date) -> bool:
    """
    오늘 알람을 동작시킬지 판단
    - mode(daily/weekdays/once)
    - 공휴일 제외 옵션
    """
    mode = cfg.get("mode", "daily").lower()

    # once 모드: 특정 날짜에만 동작
    if mode == "once":
        once_date = cfg.get("once_date")
        if not once_date:
            return False
        try:
            od = datetime.strptime(once_date, "%Y-%m-%d").date()
            return today == od
        except ValueError:
            return False

    # weekdays 모드: 토/일이면 동작 안 함
    if mode == "weekdays":
        if today.weekday() >= 5:
            return False

    # 공휴일 제외 옵션
    if cfg.get("exclude_public_holidays", False):
        cc = cfg.get("country_holidays", "KR")
        if is_public_holiday(today, cc):
            return False

    return True


def parse_times(cfg: dict):
    """
    설정 times(["06:00","06:10"...])를 (hh,mm) 튜플 리스트로 변환
    - 잘못된 값은 무시
    - 중복 제거 + 정렬
    """
    times = cfg.get("times", [])
    parsed = []
    for t in times:
        try:
            hh, mm = t.split(":")
            parsed.append((int(hh), int(mm)))
        except Exception:
            pass
    return sorted(set(parsed))


def make_message(cfg: dict, now: datetime) -> str:
    """설정 템플릿에서 멘트 생성({hh},{mm} 치환)"""
    tpl = cfg.get("message_template", "주인님 일어나세요. 현재 시간 {hh}시 {mm}분입니다.")
    return tpl.format(hh=now.strftime("%H"), mm=now.strftime("%M"))


def tts_cache_path(text: str, voice: str, rate: str) -> str:
    """
    같은 텍스트/목소리/속도 조합은 mp3를 재사용하기 위해 해시 파일명으로 캐시 저장
    """
    key = f"{voice}|{rate}|{text}".encode("utf-8")
    h = hashlib.sha256(key).hexdigest()[:24]
    return os.path.join(CACHE_DIR, f"{h}.mp3")


async def synthesize_mp3(text: str, voice: str, rate: str, out_path: str):
    """EdgeTTS로 mp3 생성(비동기)"""
    communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate)
    await communicate.save(out_path)


def play_mp3(path: str, volume: int = 100):
    """
    mpg123로 mp3 재생
    - volume(0~100)을 gain으로 완만하게 반영
    """
    gain = max(0, min(32768, int(volume) * 80))
    subprocess.run(["mpg123", "-q", "-f", str(gain), path], check=False)


def minute_key(d: date, hh: int, mm: int) -> str:
    """중복방지 키: YYYY-MM-DD_HH:MM"""
    return f"{d.isoformat()}_{hh:02d}:{mm:02d}"


def next_trigger_datetime(now: datetime, times):
    """
    현재 시각 기준으로 다음 알람 시각 찾기
    - 오늘~모레까지 탐색(안전장치)
    """
    for day_offset in range(0, 3):
        base = now.date() + timedelta(days=day_offset)
        for hh, mm in times:
            dt = datetime.combine(base, datetime.min.time()).replace(hour=hh, minute=mm)
            if dt > now:
                return dt
    return None


async def main():
    """메인 루프"""
    ensure_dir(CACHE_DIR)

    cfg = load_json(CONFIG_PATH, default={})
    state = load_json(STATE_PATH, default={"fired": {}})

    times = parse_times(cfg)
    if not times:
        print("times 설정이 비어 있거나 형식이 잘못되었습니다.")
        return

    voice = cfg.get("voice", "ko-KR-SunHiNeural")
    rate = cfg.get("rate", "+0%")
    volume = int(cfg.get("volume", 100))

    print(f"[alarm] 시작 mode={cfg.get('mode')} times={cfg.get('times')} voice={voice}")

    while True:
        now = datetime.now()
        today = now.date()

        # 오늘 동작 조건이 아니면 내일 새벽까지 대기
        if not should_run_today(cfg, today):
            tomorrow = datetime.combine(today + timedelta(days=1), datetime.min.time()).replace(minute=1)
            sleep_sec = max(5, int((tomorrow - now).total_seconds()))
            print(f"[alarm] 오늘({today}) 스킵. {sleep_sec}초 후 재확인")
            time.sleep(sleep_sec)
            continue

        # 다음 알람 시각 계산
        nxt = next_trigger_datetime(now, times)
        if not nxt:
            time.sleep(10)
            continue

        # 다음 알람까지 대기(너무 길게 한번에 sleep하지 않도록 최대 60초 단위로 쪼갬)
        sleep_sec = (nxt - now).total_seconds()
        if sleep_sec > 1:
            time.sleep(min(60, sleep_sec))
            continue

        # 중복방지: 같은 분에 이미 울렸으면 스킵
        k = minute_key(nxt.date(), nxt.hour, nxt.minute)
        if state.get("fired", {}).get(k):
            time.sleep(1)
            continue

        # 현재 시간 안내가 정확하도록 "울리는 순간"의 시간을 기준으로 멘트 생성
        speak_time = datetime.now()
        text = make_message(cfg, speak_time)

        # 캐시 mp3가 있으면 재사용, 없으면 새로 생성
        cache_path = tts_cache_path(text, voice, rate)
        if not os.path.exists(cache_path):
            try:
                await synthesize_mp3(text, voice, rate, cache_path)
            except Exception as e:
                print("[alarm] TTS 생성 실패:", e)
                time.sleep(2)
                continue

        print(f"[alarm] 울림 {k} => {text}")
        play_mp3(cache_path, volume=volume)

        # 상태 기록(이 분에는 이미 울렸음)
        state.setdefault("fired", {})[k] = True
        save_json(STATE_PATH, state)

        # once 모드면 오늘 남은 알람이 없을 때 종료
        if cfg.get("mode", "").lower() == "once":
            remaining = []
            for hh, mm in times:
                dt = datetime.combine(today, datetime.min.time()).replace(hour=hh, minute=mm)
                if dt > speak_time:
                    remaining.append(dt)
            if not remaining:
                print("[alarm] once 모드 완료. 종료")
                return

        time.sleep(1)


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\n[alarm] 사용자에 의해 종료됨")

3) 실행 방법(수동 테스트)

cd ~/edge_alarm
python3 alarm_tts.py

당장 테스트하고 싶으면 times를 현재 시간 기준으로 1~2분 뒤로 잠깐 바꿔보면 바로 확인됩니다.


4) systemd 자동 실행(부팅 시 자동 시작)

1) 서비스 파일 생성

sudo nano /etc/systemd/system/edge-alarm.service

2) 아래 내용 입력(경로는 본인 계정에 맞게 수정)

[Unit]
Description=Edge TTS Alarm (EdgeTTS cache + dedup + systemd)
After=network.target sound.target

[Service]
Type=simple
WorkingDirectory=/home/orangepi/edge_alarm
ExecStart=/usr/bin/python3 /home/orangepi/edge_alarm/alarm_tts.py
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

3) 적용 및 실행

sudo systemctl daemon-reload
sudo systemctl enable --now edge-alarm.service
sudo systemctl status edge-alarm.service

운영 팁(실제로 써보면 도움이 되는 부분)

  • 네트워크가 잠깐 끊겨도 이미 만들어둔 mp3 캐시가 있으면 재생은 계속 됩니다.
  • “같은 시간에 두 번 울림”이 싫다면 상태파일(alarm_state.json) 방식이 꽤 든든합니다.
  • 멘트/시간/평일여부는 코드가 아니라 설정파일 하나로 운영하면 나중에 유지보수가 편해요.

FAQ

Q. 공휴일 제외는 어떻게 동작해요?
A. holidays 라이브러리에서 KR 공휴일을 체크해서 해당 날짜면 스킵합니다.

Q. 스피커가 USB/블루투스면 안 나올 때가 있어요.
A. 대부분 “기본 출력 장치”가 다르게 잡혀서 생깁니다. 먼저 Ubuntu 사운드 출력 장치를 확인해 주세요.

Q. 멘트를 바꾸려면 코드를 수정해야 하나요?
A. 아니요. message_template만 바꾸면 됩니다.

이 글이 도움이 되었나요?좋아요/추천은 다시 누르면 취소됩니다.
hong
발행: 2026.02.12 최종 검토: 2026.02.12

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다