From e70910299975591d272802ad4e19528b11486259 Mon Sep 17 00:00:00 2001 From: johnoct7 Date: Fri, 6 Feb 2026 09:42:55 -0500 Subject: [PATCH] feat: add audio motion alerts to web UI Play a chime and vibrate the phone when motion is detected, so users monitoring from a phone get alerted without relying on macOS notifications. - Bell button in header to toggle audio alerts on/off - Web Audio API two-tone chime (C5/E5) on new motion events - Vibration API for mobile haptic feedback - localStorage persists mute/unmute preference - First-poll initialization prevents false alert on page load - Coexists with existing macOS osascript notifications - 73 tests passing (3 new) --- .../2026-02-06-audio-motion-alerts-design.md | 35 +++++++ tests/test_web.py | 16 ++++ web.py | 93 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 docs/plans/2026-02-06-audio-motion-alerts-design.md diff --git a/docs/plans/2026-02-06-audio-motion-alerts-design.md b/docs/plans/2026-02-06-audio-motion-alerts-design.md new file mode 100644 index 0000000..8fe7f72 --- /dev/null +++ b/docs/plans/2026-02-06-audio-motion-alerts-design.md @@ -0,0 +1,35 @@ +# Audio Motion Alerts Design + +## Problem + +When the MacBook camera is the source and the user monitors from their phone, macOS `osascript` notifications only appear on the Mac — useless when you're in another room. + +## Solution + +Play an audio chime and vibrate the phone through the web UI when motion is detected. The existing `/status` endpoint already provides `last_motion_time`, so this is entirely frontend — no backend changes needed. + +### Why not Web Push? + +Web Push (Service Workers + Push API) requires HTTPS. BabyPing runs over HTTP on the LAN (`http://192.168.x.x:8080`), which is not a secure context. Adding self-signed HTTPS would introduce cert management complexity and poor UX on iOS (certificate trust settings). Audio alerts solve the same core problem — phone makes noise when baby moves — with zero setup. + +## Design + +- **Bell button** in the header (next to clock) — tap to enable/disable audio alerts +- **Web Audio API** generates a two-tone ascending chime (C5 → E5, ~0.45s) — gentle but noticeable +- **Vibration API** pulses the phone on motion (200ms-100ms-200ms pattern) +- **localStorage** persists the mute/unmute preference across page refreshes +- **AudioContext** created on first bell tap (satisfies browser autoplay policy via user gesture) +- **First-poll initialization** prevents false alerts on page load — `lastMotionAlerted` is set to the current `last_motion_time` on first status response + +### How it triggers + +The existing status polling (every 3s) already fetches `last_motion_time`. When this timestamp is newer than `lastMotionAlerted`, a new motion event occurred (these only fire once per cooldown period, matching when macOS notifications are sent). The chime plays and vibration fires. + +### Coexistence + +macOS `osascript` notifications continue to work alongside audio alerts. Both fire independently — useful when at the Mac vs. monitoring remotely. + +## Files Changed + +- `web.py`: Bell button HTML, notify-btn CSS, audio alert JavaScript +- `tests/test_web.py`: 3 new tests (bell button, audio scripts, vibration API in HTML) diff --git a/tests/test_web.py b/tests/test_web.py index 5c9d950..45e451a 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -147,3 +147,19 @@ def test_index_includes_roi_ui(self, roi_client): assert b"roi-btn" in resp.data assert b"roi-overlay" in resp.data assert b"roi-canvas" in resp.data + + +class TestWebAudioAlerts: + def test_index_includes_notify_button(self, client): + resp = client.get("/") + assert b"notify-btn" in resp.data + + def test_index_includes_audio_scripts(self, client): + resp = client.get("/") + assert b"toggleAudio" in resp.data + assert b"playAlertSound" in resp.data + assert b"AudioContext" in resp.data + + def test_index_includes_vibration_api(self, client): + resp = client.get("/") + assert b"navigator.vibrate" in resp.data diff --git a/web.py b/web.py index d2aa6ac..f16fd45 100644 --- a/web.py +++ b/web.py @@ -358,6 +358,32 @@ def snapshot_file(filename): .night-icon { font-size: 14px; line-height: 1; } +/* ── Audio alert button ── */ +.notify-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid var(--glass-border); + background: var(--surface); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + color: var(--text-muted); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: color 0.3s, border-color 0.3s, background 0.3s; +} + +.notify-btn.active { + color: var(--amber); + border-color: rgba(240,198,116,0.3); + background: var(--amber-soft); +} + +.notify-btn svg { width: 14px; height: 14px; } + /* ── Snapshots panel ── */ .snap-panel { flex-shrink: 0; @@ -580,6 +606,9 @@ def snapshot_file(filename):
+
@@ -655,6 +684,59 @@ def snapshot_file(filename): /* ROI state (initialized early for status polling) */ var currentRoi = null; +/* Audio alert state (initialized early for status polling) */ +var audioCtx = null; +var audioEnabled = false; +var lastMotionAlerted = 0; +var statusInitialized = false; +var notifyBtn = document.getElementById('notify-btn'); + +try { audioEnabled = localStorage.getItem('bp_audio') === '1'; } catch(e) {} + +function updateNotifyBtn() { + if (audioEnabled) { notifyBtn.classList.add('active'); } + else { notifyBtn.classList.remove('active'); } +} +updateNotifyBtn(); + +function ensureAudio() { + if (!audioCtx) { + try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {} + } + if (audioCtx && audioCtx.state === 'suspended') { audioCtx.resume(); } +} + +function playAlertSound() { + if (!audioCtx) return; + try { + var now = audioCtx.currentTime; + var o1 = audioCtx.createOscillator(); + var g1 = audioCtx.createGain(); + o1.connect(g1); g1.connect(audioCtx.destination); + o1.frequency.value = 523; + g1.gain.setValueAtTime(0.2, now); + g1.gain.exponentialRampToValueAtTime(0.001, now + 0.3); + o1.start(now); o1.stop(now + 0.3); + var o2 = audioCtx.createOscillator(); + var g2 = audioCtx.createGain(); + o2.connect(g2); g2.connect(audioCtx.destination); + o2.frequency.value = 659; + g2.gain.setValueAtTime(0.2, now + 0.15); + g2.gain.exponentialRampToValueAtTime(0.001, now + 0.45); + o2.start(now + 0.15); o2.stop(now + 0.45); + } catch(e) {} +} + +function toggleAudio() { + audioEnabled = !audioEnabled; + try { localStorage.setItem('bp_audio', audioEnabled ? '1' : '0'); } catch(e) {} + updateNotifyBtn(); + if (audioEnabled) { + ensureAudio(); + playAlertSound(); + } +} + /* Clock */ function updateClock() { const d = new Date(); @@ -722,6 +804,17 @@ def snapshot_file(filename): streamWrap.classList.remove('has-motion'); motionText.textContent = 'No motion'; } + + /* Audio alert on new motion events */ + if (!statusInitialized) { + lastMotionAlerted = data.last_motion_time || 0; + statusInitialized = true; + } else if (audioEnabled && data.last_motion_time && data.last_motion_time > lastMotionAlerted) { + lastMotionAlerted = data.last_motion_time; + ensureAudio(); + playAlertSound(); + try { if (navigator.vibrate) navigator.vibrate([200, 100, 200]); } catch(e) {} + } }).catch(function() {}); }