아침에 알람이 울리긴 하는데…
“몇 시인지 말로 알려주면 진짜 바로 일어나겠는데?” 싶을 때가 있죠.
이번 글에서는 오렌지파이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"
}
핵심 옵션 설명
modedaily: 매일weekdays: 평일만(토/일 제외)once: 특정 날짜once_date에만 1회 실행
times: 울릴 시간을 배열로 관리message_template:{hh},{mm}가 현재 시각으로 자동 치환exclude_public_holidays: 공휴일 제외 여부country_holidays: 한국은"KR"
2) 파이썬 실행 코드(alarm_tts.py)
이 코드는 아래 3가지를 “운영 가능한 수준”으로 묶는 게 포인트입니다.
- EdgeTTS 캐시: 같은 문장은 mp3를 저장해 재사용
- 중복방지:
YYYY-MM-DD_HH:MM키로 “이미 울림” 기록 - 자동실행: 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만 바꾸면 됩니다.
이 글이 도움이 되었나요?좋아요/추천은 다시 누르면 취소됩니다.