やりたいこと
- iPhone のカレンダーに予定を入れるだけでOK
- 予定の 20分前(一部イベントは 120分前)に、
- 2階の常時稼働 Mac mini
- 1階の Amazon Echo Pop(Bluetooth)
- 任意の第2の出力デバイス(サウンドバー、TV、外部スピーカーなど)
から 同時に mp3 アラームを再生する
- Apple Music 経由でも可能だが、月額課金(1080円)が必要。今回は無料構成で実現
環境
- macOS(常時稼働の Mac mini)
- Homebrew 導入済み
- Amazon Echo Pop(Bluetooth 接続)
- iPhone(標準カレンダー使用、iCloud で Mac と同期)
事前準備(超重要)
1. Mac mini をスリープさせない
- システム設定 → バッテリー / 電源アダプタ
- ディスプレイをオフにしても Mac がスリープしないように設定
- 「ネットワークアクセスによるスリープ解除」を有効にしておくとリモートから叩ける
2. iCloud カレンダー同期(iPhone ↔ Mac)
- iPhone と Mac mini が 同じ iCloud カレンダーを参照していること
- iPhone に入れた予定が 自動で Mac 側カレンダーに反映される状態にする
- 本仕組みは Mac 側の予定を icalBuddy で取得します
3. Bluetooth ペアリング(Mac mini ↔ Echo Pop)
- Echo 側で「アレクサ、Bluetoothをオンにして」と発話し、Mac とペアリング
- macOS の サウンド > 出力に Echo が表示されることを確認
- 普段は 出力デバイス や本体スピーカーを既定出力に戻しておいて問題なし
4. 出力デバイスの確認(IDを控える)
(任意)出力先の切替ユーティリティを入れる場合:
brew install switchaudio-osx
現在の出力デバイスの確認:
SwitchAudioSource -c
mpv が認識する出力デバイス一覧(Echo/出力デバイス の ID を控える):
mpv --audio-device=help
5. アラーム音 mp3 の準備
任意の mp3 を 固定パスに置く(本記事では以下に統一):
/usr/bin/alert.mp3
補足: macOS のバージョンによっては /usr/bin が書き込み不可のことがあります。
その場合は例として /Users/USERNAME/Music/Alerts/alert.mp3 を使い、本文中のパスを置き換えてください。
必要なツールのインストール
brew install ical-buddy
brew install mpv
Apple Silicon の方: icalBuddy の実体が /opt/homebrew/bin/icalBuddy になる場合があります。後述のスクリプトでは ICALBUDDY 環境変数で上書きできます。
スクリプト(フル版)
以降のコードは コピー → パスやデバイスIDだけ置き換えでそのまま使えます。
個人情報に関わる箇所はダミー(USERNAME、デバイスID、イベント名)にしてあります。
①単発再生テスト用:~/bin/play_once.sh
#!/bin/zsh
# 1回だけmp3を鳴らして終了
/usr/bin/afplay "/usr/bin/alert.mp3"
chmod +x ~/bin/play_once.sh
②同時再生:~/bin/play_and_cleanup.sh
#!/bin/zsh
# Echo と 出力デバイス に同じ mp3 を同時再生し、LaunchAgent を掃除
set -euo pipefail
mp3="$1"; label="$2"; plist="$3"
MPV="/usr/local/bin/mpv"
ECHO="coreaudio/AA-BB-CC-DD-EE-FF:output" # ← mpv --audio-device=help で確認した Echo の ID に置換
SPEAKER="coreaudio/11-22-33-44-55-66:output" # ← 同上、出力デバイス の ID に置換
# 同時再生(フィルタなし安定版)
"$MPV" --no-video --volume=100 --audio-device="$ECHO" "$mp3" || true &
"$MPV" --no-video --volume=100 --audio-device="$SPEAKER" "$mp3" || true &
wait
# 1回きりの LaunchAgent をクリーンアップ
launchctl remove "$label" >/dev/null 2>&1 || true
/bin/rm -f "$plist" >/dev/null 2>&1 || true
exit 0
chmod +x ~/bin/play_and_cleanup.sh
xattr -d com.apple.quarantine ~/bin/play_and_cleanup.sh 2>/dev/null || true
③カレンダー予定からジョブ登録(フル版):~/bin/register_awaken.sh
#!/bin/zsh
# icalBuddy の出力(タイトル行 → 次行が "YYYY-MM-DD at HH:MM:SS" or "YYYY-MM-DD")をパース。
# 開始20分前(タイトルが一致する一部は120分前)に mp3 を1回だけ鳴らす LaunchAgent を作成。
# 【重複防止】同一アラート(同一時刻×同一タイトル)のラベルがあればスキップ。
# 【自動掃除】過去になったラベル(ファイル/登録)を定期的に削除。
set -euo pipefail
LOG=/tmp/register_awaken.log
echo "=== $(date) start ===" >>"$LOG"
# ---- 設定(環境に合わせて必要なら上書き可能)----
ICALBUDDY="${ICALBUDDY:-/usr/local/bin/icalBuddy}"
MP3_PATH="${MP3_PATH:-/Users/USERNAME/Music/Alerts/alert.mp3}" # ← 置き換え推奨
LOOKAHEAD_DAYS=${LOOKAHEAD_DAYS:-60}
MIN_LEAD_SECONDS=60 # 実行まで1分未満は登録しない
LABEL_PREFIX="com.user.awaken" # ← 任意のドメインに変更可
LAUNCH_DIR="$HOME/Library/LaunchAgents"
RUNNER="$HOME/bin/play_and_cleanup.sh" # ← mpv 同時再生スクリプトを呼ぶ
SKIP_ALLDAY="${SKIP_ALLDAY:-yes}" # 終日予定をスキップするか
ALLDAY_DEFAULT_TIME="${ALLDAY_DEFAULT_TIME:-09:00:00}" # 終日を扱う場合の時刻(HH:MM:SS)
# ---------------------------------------------------
# ===== 二重起動ガード(5分で時限ロック) =====
LOCK=/tmp/register_awaken.lock
if ( set -o noclobber; echo "$$" > "$LOCK" ) 2>/dev/null; then
trap 'rm -f "$LOCK"' EXIT
else
echo "locked: another process running, exit" >>"$LOG"
exit 0
fi
# ============================================
# 必須ファイル確認
if [[ ! -x "$RUNNER" ]]; then
echo "RUNNER not found or not executable: $RUNNER" >>"$LOG"
exit 1
fi
mkdir -p "$LAUNCH_DIR"
domain="gui/$(id -u)"
now_epoch=$(date +%s)
# ---- 古いジョブの自動掃除 ----
for p in "$LAUNCH_DIR"/${LABEL_PREFIX}.*.plist(N); do
base="${p:t}"
# 形式: com.user.awaken.<epoch>.<hash>.plist
alert_epoch="${base#${LABEL_PREFIX}.}"; alert_epoch="${alert_epoch%%.*}"
[[ "$alert_epoch" == <-> ]] || continue
if (( alert_epoch < now_epoch - 300 )); then
label="${base%.plist}"
launchctl bootout "$domain" "$p" >/dev/null 2>&1 || true
rm -f "$p"
echo "gc: removed past job $label" >>"$LOG"
fi
done
# ---- icalBuddy 出力 ----
out="$("$ICALBUDDY" -n -nrd -eed -b "" -df "%Y-%m-%d" -tf "%H:%M:%S" \
eventsFrom:today to:today+"$LOOKAHEAD_DAYS" 2>>"$LOG" || true)"
[[ -z "$out" ]] && { echo "no events (next $LOOKAHEAD_DAYS days)" >>"$LOG"; echo "=== $(date) end ===" >>"$LOG"; exit 0; }
i=0
current_title=""
echo "$out" | while IFS= read -r line; do
[[ -z "$line" ]] && continue
if [[ "$line" != [[:space:]]* ]]; then
# タイトル行
current_title="$line"
continue
fi
# 日時行(例: "2025-09-13 at 11:00:00" or "2025-09-15")
timestr="$(echo "$line" | xargs)"
start_str=""
allday="no"
if [[ "$timestr" == *" at "* ]]; then
start_date="${timestr%% at *}"
start_time="${timestr##* at }"
[[ "$start_time" != *:*:* ]] && start_time="${start_time}:00"
start_str="$start_date $start_time"
else
# 終日
if [[ "$SKIP_ALLDAY" == "yes" ]]; then
echo "skip allday: $current_title" >>"$LOG"
continue
fi
start_date="$timestr"
start_str="$start_date $ALLDAY_DEFAULT_TIME"
allday="yes"
fi
# "YYYY-MM-DD HH:MM:SS" を epoch に
if ! start_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$start_str" +%s 2>>"$LOG"); then
echo "parse fail: $start_str ($current_title)" >>"$LOG"
continue
fi
# リードタイム設定(タイトルに応じて 20分 / 120分)
# 例:重要な会議やレッスンは 120 分前、それ以外は 20 分前
if [[ "$current_title" == *"重要イベント"* || "$current_title" == *"レッスン"* ]]; then
lead=120; suffix=" (-120m)"
else
lead=20; suffix=" (-20m)"
fi
alert_epoch=$(( start_epoch - lead * 60 ))
(( alert_epoch <= now_epoch + MIN_LEAD_SECONDS )) && { echo "skip past/near: $current_title" >>"$LOG"; continue; }
# ラベル(同一アラート×同一タイトルは同じラベルに)
hash=$(printf "%s" "$current_title" | /usr/bin/shasum | cut -c1-6)
label="${LABEL_PREFIX}.${alert_epoch}.${hash}"
plist="$LAUNCH_DIR/$label.plist"
# 重複防止
if [[ -f "$plist" ]]; then
echo "skip dup(label exists): $label | $current_title" >>"$LOG"
continue
fi
# plist 生成
y=$(date -r "$alert_epoch" +%Y)
mo=$(date -r "$alert_epoch" +%-m)
d=$(date -r "$alert_epoch" +%-d)
h=$(date -r "$alert_epoch" +%-H)
mi=$(date -r "$alert_epoch" +%-M)
cat > "$plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${RUNNER}</string>
<string>${MP3_PATH}</string>
<string>${label}</string>
<string>${plist}</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Year</key><integer>${y}</integer>
<key>Month</key><integer>${mo}</integer>
<key>Day</key><integer>${d}</integer>
<key>Hour</key><integer>${h}</integer>
<key>Minute</key><integer>${mi}</integer>
</dict>
<key>ProcessType</key><string>Background</string>
<key>StandardOutPath</key><string>/tmp/${label}.out</string>
<key>StandardErrorPath</key><string>/tmp/${label}.err</string>
</dict></plist>
EOF
/usr/bin/plutil -lint "$plist" >>"$LOG" 2>&1 || { echo "bad plist: $plist" >>"$LOG"; rm -f "$plist"; continue; }
# 読み込み(再読み込みもクリーンに)
launchctl bootout "$domain" "$plist" >/dev/null 2>&1 || true
launchctl bootstrap "$domain" "$plist"
launchctl enable "$domain/$label"
echo "loaded: $label | ${current_title}${suffix} | start=${start_str} | alert=$(date -r "$alert_epoch" "+%Y-%m-%d %H:%M") allday=${allday}" >>"$LOG"
((i++))
done
echo "done: ${i} jobs loaded" >>"$LOG"
echo "=== $(date) end ===" >>"$LOG"
chmod +x ~/bin/register_awaken.sh
cron 登録(毎日 2:00 にスケジュール再生成)
0 2 * * * /bin/zsh ~/bin/register_awaken.sh >>/tmp/register_awaken.log 2>&1
動作確認
- Echo 単体
mpv --no-video --audio-device=coreaudio/AA-BB-CC-DD-EE-FF:output --volume=100 "/usr/bin/alert.mp3"
- 出力デバイス 単体
mpv --no-video --audio-device=coreaudio/11-22-33-44-55-66:output --volume=100 "/usr/bin/alert.mp3"
- 同時再生スクリプト(手動)
~/bin/play_and_cleanup.sh "/usr/bin/alert.mp3" test.label /tmp/test.plist
よくあるハマりポイント
- LaunchAgent が起動しない → 状態やログを確認:
launchctl list | grep com.user.awaken
tail -n 200 /tmp/register_awaken.log
tail -n 200 /tmp/com.user.awaken*.err
- iPhone で入れた予定が拾われない → iCloud 同期・対象カレンダーを再確認(Mac のカレンダーに見えているか)
まとめ
- iPhone に予定を入れるだけで、Echo + 出力デバイス から同時にアラームが鳴る
- 特定イベント(例:重要イベント / レッスン)は 120分前、それ以外は 20分前に自動通知
- Apple Music 課金不要で、自作 mp3 を Echo に鳴らせる
- LaunchAgent + cron による 自動登録・自動掃除で完全放置運用が可能 🎉