Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/plans/2026-02-06-audio-motion-alerts-design.md
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
93 changes: 93 additions & 0 deletions web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -580,6 +606,9 @@ def snapshot_file(filename):
<header class="header">
<div class="logo">BabyPing</div>
<div class="header-right">
<button class="notify-btn" id="notify-btn" onclick="toggleAudio()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>
</button>
<span class="clock" id="clock"></span>
<div class="live-pill">
<span class="live-dot"></span>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {});
}

Expand Down