0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Mac mini と Amazon Echo を連動して、iPhoneカレンダー予定の20分前にアラームmp3を同時再生する仕組みを作った

Posted at

やりたいこと

  • 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 による 自動登録・自動掃除で完全放置運用が可能 🎉
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?