diff --git a/docs/spikes/719-webrtc-coep-turn-audio-report.md b/docs/spikes/719-webrtc-coep-turn-audio-report.md
new file mode 100644
index 00000000..2c7fd83b
--- /dev/null
+++ b/docs/spikes/719-webrtc-coep-turn-audio-report.md
@@ -0,0 +1,92 @@
+# Spike #719 (ISP-049): WebRTC / COOP+COEP / background kill / dispatch audio
+
+Status: **partial**. This report records what was checked against the code, one
+experiment, and one fix. The device-lab work the issue asks for is not done (see
+"Not done").
+
+## 1. Claims in the issue, checked against the repository
+
+Every row below was checked with `grep` or by reading the file, at commit `f87269c`.
+
+| Claim in the issue | What the repository shows |
+|---|---|
+| `server/middleware/csp.ts` emits `COOP: same-origin` + `COEP: credentialless` | **No.** `csp.ts` sets neither. The headers are set in `vite.config.ts` (`server.headers` and `preview.headers`), and `test/coep-headers-spike.test.js` asserts they also appear in `server/index.ts`. |
+| `webrtc.ts` allocates TURN at `turn:turn.helphone.org:3478` with credentials from `server/routes/turn-credentials` | **No.** `src/lib/webrtc.ts` is 27 lines: a one-shot ICE benchmark using STUN only (`stun.l.google.com`, `stun.cloudflare.com`). There is no `turn:` URL, no credentials route (the path does not exist) and no coturn config anywhere in the repo. |
+| `iceCandidatePoolSize: 10` | The only value in the repo is `2` (in that benchmark). |
+| `iceConnectionState` flips to `disconnected` with no resurrection | `iceConnectionState` is not used anywhere in `src/` or `server/`. There is no long-lived peer connection to lose. |
+| COEP blocks TURN allocation, so calls fall back to host/STUN | **Not supported by the experiment in section 2** (UDP relay connects under COEP). |
+| Reconnect needs jittered backoff | Already implemented: `src/lib/wsClient.ts` does exponential backoff with up to 50% jitter. |
+| Web Push wake path | Not present (no `pushManager`, VAPID or `push` handler). Not built here. |
+| Battery-aware policy | Partly present: `src/services/networkTransport.js` already reads `navigator.getBattery`. No NAT-type policy table exists. |
+| `test/audio-alert.test.js` is green | **It was failing on `main`.** vitest 4 cannot `new` an arrow-function mock, so both tests threw "is not a constructor". Fixed in this PR. |
+| `audioAlert.ts` has no autoplay unlock | It has `unlock()`, but nothing ever called it, and `play()` could hang forever (section 3). |
+
+## 2. Experiment: does COOP/COEP block TURN relay in a real browser?
+
+`scripts/spikes/webrtc_coep_turn_experiment.mjs` starts a real TURN server
+(`node-turn`, loopback) and serves a page under three header sets. In Chrome it
+opens two `RTCPeerConnection`s with `iceTransportPolicy: 'relay'`, so a data
+channel can open only if the TURN allocation succeeds and traffic is relayed.
+
+Run on Chrome 155.0.8059.80 (macOS), 10 runs per row. Raw data:
+`docs/spikes/results/webrtc-coep-turn.json`.
+
+| Headers | TURN transport | crossOriginIsolated | Connected | Via relay pair | Median connect |
+|---|---|---|---|---|---|
+| none (control) | UDP | false | 10/10 | 10/10 | 167 ms |
+| COOP same-origin + COEP credentialless (the repo's config) | UDP | true | **10/10** | 10/10 | 252 ms |
+| COOP same-origin + COEP require-corp | UDP | true | **10/10** | 10/10 | 169 ms |
+| all three | TCP | – | 0/10 | 0/10 | – |
+
+**Reading the result.**
+
+- Over UDP, cross-origin isolation did **not** stop relay allocation or media
+ flow, including with the repo's exact headers. This matches how the browser
+ works: COEP/CORP govern HTTP resource loads, and ICE/TURN traffic is not an
+ HTTP fetch, so a TURN 401 has no `CORP` header to be missing.
+- The **TCP rows are inconclusive, not evidence of a COEP effect**. They fail
+ identically in the no-headers control, so the cause is this local TURN setup
+ (`node-turn` over TCP with Chrome), not the headers.
+- If production calls fail to connect, the cause is more likely elsewhere:
+ no TURN server is configured at all today (section 1), or the credential
+ `fetch()` (which *is* an HTTP request and *would* be subject to COEP/CORS if it
+ were cross-origin) is what fails. Keeping that endpoint same-origin avoids it.
+
+**Limits.** One machine, one browser, loopback only. This shows what the
+headers do to relay allocation. It does not emulate carrier NATs, Safari/Firefox,
+or `turns:` (TLS).
+
+## 3. Fix: `EmergencyAudioAlert` could hang silently when autoplay is blocked
+
+`play()` awaited `AudioContext.resume()`. When the browser's autoplay policy
+blocks audio, that promise stays pending forever (it neither resolves nor
+rejects), so a siren triggered without a recent user gesture hung, made no sound,
+and recorded nothing. `unlock()` existed but was never called.
+
+Changes (`src/lib/audioAlert.ts`, `src/lib/telemetry.ts`, `src/pages/Help.tsx`):
+
+- `play()` waits at most `RESUME_TIMEOUT_MS` (1 s). If the context is still not
+ running it vibrates (`[500, 250, 500]`, best effort) and returns `'blocked'`
+ instead of hanging; otherwise it returns `'played'`.
+- `unlock()` now reports whether the context is running.
+- `armOnFirstGesture()` unlocks audio on the first user gesture so a later
+ gesture-less alert can sound. `Help.tsx` arms it on mount.
+- `trackAudioAlert('played' | 'blocked', { fallback })` emits an `audio.alert`
+ span, so the blocked rate is `blocked / (played + blocked)`. `getStats()` exposes
+ the same counts locally.
+- The "Test siren" button now says when sound is blocked instead of claiming it played.
+
+Tests: `test/audio-alert.test.js` (10 tests; 9 fail on the old code).
+
+## 4. Not done
+
+Needs a real TURN deployment, devices or product decisions, so not attempted:
+
+- TURN credential proxy / per-call ephemeral credentials (there is no TURN server to proxy yet).
+- Web Push wake path (VAPID keys, `server/routes/push`, service-worker `push` handler, permission UX).
+- NAT-type × battery × network policy table and the Moto G measurements behind it.
+- Playwright `webrtc-nat` and `background-kill` projects; background-kill drills on locked phones.
+- `ice*` / `turn401` / `wsReaped` / `pushDelivered` metrics, dashboards and alerts in `server/middleware/metrics.ts`.
+- NAT-mix and background-churn peers in `server/tests/load.js`.
+- Rejoin allowlist in `rateLimiter.ts`; reap timing in `keepAlive`; `crashRecovery.ts` (not examined).
+- The ops runbook in `docs/webrtc-architecture.md`.
diff --git a/docs/spikes/results/webrtc-coep-turn.json b/docs/spikes/results/webrtc-coep-turn.json
new file mode 100644
index 00000000..046a093d
--- /dev/null
+++ b/docs/spikes/results/webrtc-coep-turn.json
@@ -0,0 +1,168 @@
+{
+ "generatedAt": "2026-10-20T08:33:03.502Z",
+ "browser": "155.0.8059.80",
+ "turn": "node-turn on 127.0.0.1 (UDP+TCP), long-term credentials",
+ "runsPerVariant": 10,
+ "timeoutMs": 8000,
+ "variants": [
+ {
+ "name": "control: no isolation headers",
+ "transport": "udp",
+ "headers": {},
+ "crossOriginIsolated": false,
+ "connected": "10/10",
+ "viaRelayPair": "10/10",
+ "medianMsToConnect": 167,
+ "consoleErrors": [],
+ "sample": {
+ "connected": true,
+ "msToConnect": 100,
+ "iceConnectionState": {
+ "a": "connected",
+ "b": "connected"
+ },
+ "candidateTypes": [
+ "relay"
+ ],
+ "selectedPair": {
+ "local": "relay",
+ "remote": "relay"
+ },
+ "crossOriginIsolated": false,
+ "consoleErrors": []
+ }
+ },
+ {
+ "name": "repo config: COOP same-origin + COEP credentialless",
+ "transport": "udp",
+ "headers": {
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "credentialless"
+ },
+ "crossOriginIsolated": true,
+ "connected": "10/10",
+ "viaRelayPair": "10/10",
+ "medianMsToConnect": 252,
+ "consoleErrors": [],
+ "sample": {
+ "connected": true,
+ "msToConnect": 303,
+ "iceConnectionState": {
+ "a": "connected",
+ "b": "connected"
+ },
+ "candidateTypes": [
+ "relay"
+ ],
+ "selectedPair": {
+ "local": "relay",
+ "remote": "relay"
+ },
+ "crossOriginIsolated": true,
+ "consoleErrors": []
+ }
+ },
+ {
+ "name": "strict: COOP same-origin + COEP require-corp",
+ "transport": "udp",
+ "headers": {
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "require-corp"
+ },
+ "crossOriginIsolated": true,
+ "connected": "10/10",
+ "viaRelayPair": "10/10",
+ "medianMsToConnect": 169,
+ "consoleErrors": [],
+ "sample": {
+ "connected": true,
+ "msToConnect": 145,
+ "iceConnectionState": {
+ "a": "connected",
+ "b": "connected"
+ },
+ "candidateTypes": [
+ "relay"
+ ],
+ "selectedPair": {
+ "local": "relay",
+ "remote": "relay"
+ },
+ "crossOriginIsolated": true,
+ "consoleErrors": []
+ }
+ },
+ {
+ "name": "control: no isolation headers",
+ "transport": "tcp",
+ "headers": {},
+ "crossOriginIsolated": false,
+ "connected": "0/10",
+ "viaRelayPair": "0/10",
+ "medianMsToConnect": null,
+ "consoleErrors": [],
+ "sample": {
+ "connected": false,
+ "msToConnect": null,
+ "iceConnectionState": {
+ "a": "new",
+ "b": "new"
+ },
+ "candidateTypes": [],
+ "selectedPair": null,
+ "crossOriginIsolated": false,
+ "consoleErrors": []
+ }
+ },
+ {
+ "name": "repo config: COOP same-origin + COEP credentialless",
+ "transport": "tcp",
+ "headers": {
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "credentialless"
+ },
+ "crossOriginIsolated": true,
+ "connected": "0/10",
+ "viaRelayPair": "0/10",
+ "medianMsToConnect": null,
+ "consoleErrors": [],
+ "sample": {
+ "connected": false,
+ "msToConnect": null,
+ "iceConnectionState": {
+ "a": "new",
+ "b": "new"
+ },
+ "candidateTypes": [],
+ "selectedPair": null,
+ "crossOriginIsolated": true,
+ "consoleErrors": []
+ }
+ },
+ {
+ "name": "strict: COOP same-origin + COEP require-corp",
+ "transport": "tcp",
+ "headers": {
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "require-corp"
+ },
+ "crossOriginIsolated": true,
+ "connected": "0/10",
+ "viaRelayPair": "0/10",
+ "medianMsToConnect": null,
+ "consoleErrors": [],
+ "sample": {
+ "connected": false,
+ "msToConnect": null,
+ "iceConnectionState": {
+ "a": "new",
+ "b": "new"
+ },
+ "candidateTypes": [],
+ "selectedPair": null,
+ "crossOriginIsolated": true,
+ "consoleErrors": []
+ }
+ }
+ ]
+}
diff --git a/scripts/spikes/webrtc_coep_turn_experiment.mjs b/scripts/spikes/webrtc_coep_turn_experiment.mjs
new file mode 100644
index 00000000..b647e29f
--- /dev/null
+++ b/scripts/spikes/webrtc_coep_turn_experiment.mjs
@@ -0,0 +1,233 @@
+#!/usr/bin/env node
+/**
+ * Spike ISP-049 (#719) — does cross-origin isolation block WebRTC TURN relay?
+ *
+ * The issue claims COOP `same-origin` + COEP `credentialless` make Chromium
+ * refuse TURN allocation, forcing host/STUN fallback that fails on symmetric
+ * NAT. This script tests that claim directly. It starts a real TURN server
+ * (node-turn) on loopback, serves a page under each header set, and in a real
+ * Chrome opens two RTCPeerConnections with `iceTransportPolicy: 'relay'`, so
+ * a data channel can only open if the TURN allocation succeeds and traffic is
+ * relayed. It reports whether it connected and which candidate pair carried it.
+ *
+ * It repeats each header set over TURN/UDP and TURN/TCP.
+ *
+ * Usage:
+ * npm i --no-save node-turn # test-only dependency, not in package.json
+ * node scripts/spikes/webrtc_coep_turn_experiment.mjs
+ * [--runs 5] [--timeout 10000]
+ * [--executable /path/to/chrome] (or CHROMIUM_PATH env)
+ * [--headed]
+ * [--out docs/spikes/results/webrtc-coep-turn.json]
+ *
+ * Browser: uses --executable / CHROMIUM_PATH if given, else the system Chrome
+ * (Playwright channel "chrome"), else Playwright's bundled Chromium.
+ *
+ * Limits: one machine, loopback only. It proves what COOP/COEP do to relay
+ * allocation in the browser. It does NOT emulate carrier NATs.
+ */
+
+import http from "node:http";
+import { mkdirSync, writeFileSync } from "node:fs";
+import { dirname } from "node:path";
+import { chromium } from "playwright-core";
+
+const args = process.argv.slice(2);
+const opt = (name, fallback) => {
+ const i = args.indexOf(`--${name}`);
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : fallback;
+};
+const RUNS = Number(opt("runs", 5));
+const TIMEOUT_MS = Number(opt("timeout", 10_000));
+const EXECUTABLE = opt("executable", process.env.CHROMIUM_PATH);
+const HEADED = args.includes("--headed");
+const OUT = opt("out", null);
+
+const TURN_PORT = 34780;
+const TURN_USER = "helphone";
+const TURN_PASS = "spike-secret";
+
+let Turn;
+try {
+ ({ default: Turn } = await import("node-turn"));
+} catch {
+ console.error("node-turn is not installed. Run: npm i --no-save node-turn");
+ process.exit(2);
+}
+
+const TRANSPORTS = ["udp", "tcp"];
+const VARIANTS = [
+ { name: "control: no isolation headers", headers: {} },
+ {
+ name: "repo config: COOP same-origin + COEP credentialless",
+ headers: {
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "credentialless",
+ },
+ },
+ {
+ name: "strict: COOP same-origin + COEP require-corp",
+ headers: {
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "require-corp",
+ },
+ },
+];
+
+const PAGE = `
webrtc coep spike
+`;
+
+function startPageServer() {
+ const server = http.createServer((req, res) => {
+ if (req.url.startsWith("/favicon.ico")) { res.writeHead(204).end(); return; }
+ const m = /^\/v(\d+)$/.exec(req.url.split("?")[0]);
+ const variant = m && VARIANTS[Number(m[1])];
+ if (!variant) { res.writeHead(404).end(); return; }
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", ...variant.headers });
+ res.end(PAGE);
+ });
+ return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server)));
+}
+
+const turn = new Turn({
+ authMech: "long-term",
+ credentials: { [TURN_USER]: TURN_PASS },
+ listeningPort: TURN_PORT,
+ listeningIps: ["127.0.0.1"],
+ relayIps: ["127.0.0.1"],
+ debugLevel: "OFF",
+});
+turn.start();
+
+const pageServer = await startPageServer();
+const origin = `http://127.0.0.1:${pageServer.address().port}`;
+
+const launchOptions = {
+ headless: !HEADED,
+ args: ["--allow-loopback-in-peer-connection", "--disable-features=WebRtcHideLocalIpsWithMdns"],
+};
+let browser;
+try {
+ if (EXECUTABLE) browser = await chromium.launch({ ...launchOptions, executablePath: EXECUTABLE });
+ else {
+ try { browser = await chromium.launch({ ...launchOptions, channel: "chrome" }); }
+ catch { browser = await chromium.launch(launchOptions); }
+ }
+} catch (err) {
+ console.error(`Could not launch a browser: ${err.message}\nPass --executable /path/to/chrome or set CHROMIUM_PATH.`);
+ turn.stop?.(); pageServer.close();
+ process.exit(2);
+}
+
+const report = {
+ generatedAt: new Date().toISOString(),
+ browser: browser.version(),
+ turn: "node-turn on 127.0.0.1 (UDP+TCP), long-term credentials",
+ runsPerVariant: RUNS,
+ timeoutMs: TIMEOUT_MS,
+ variants: [],
+};
+
+for (const transport of TRANSPORTS) for (let v = 0; v < VARIANTS.length; v++) {
+ const variant = VARIANTS[v];
+ const runs = [];
+ for (let i = 0; i < RUNS; i++) {
+ const page = await browser.newPage();
+ const consoleErrors = [];
+ page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
+ await page.goto(`${origin}/v${v}`);
+ const result = await page.evaluate(
+ (p) => window.run(p),
+ { turnUrl: `turn:127.0.0.1:${TURN_PORT}?transport=${transport}`, user: TURN_USER, pass: TURN_PASS, timeoutMs: TIMEOUT_MS },
+ );
+ runs.push({ ...result, consoleErrors });
+ await page.close();
+ }
+ const connected = runs.filter((r) => r.connected).length;
+ const relayed = runs.filter((r) => r.selectedPair && (r.selectedPair.local === "relay" || r.selectedPair.remote === "relay")).length;
+ report.variants.push({
+ name: variant.name,
+ transport,
+ headers: variant.headers,
+ crossOriginIsolated: runs.every((r) => r.crossOriginIsolated),
+ connected: `${connected}/${RUNS}`,
+ viaRelayPair: `${relayed}/${RUNS}`,
+ medianMsToConnect: median(runs.filter((r) => r.connected).map((r) => r.msToConnect)),
+ consoleErrors: [...new Set(runs.flatMap((r) => r.consoleErrors))],
+ sample: runs[0],
+ });
+}
+
+function median(xs) {
+ if (!xs.length) return null;
+ const s = [...xs].sort((a, b) => a - b);
+ return s[Math.floor(s.length / 2)];
+}
+
+await browser.close();
+pageServer.close();
+turn.stop?.();
+
+console.log(`\nBrowser: ${report.browser} (relay-only peer connection through a local TURN server)\n`);
+console.table(report.variants.map((v) => ({
+ variant: v.name,
+ transport: v.transport,
+ crossOriginIsolated: v.crossOriginIsolated,
+ connected: v.connected,
+ "via relay pair": v.viaRelayPair,
+ "median ms": v.medianMsToConnect,
+ "console errors": v.consoleErrors.length,
+})));
+for (const v of report.variants) {
+ if (v.consoleErrors.length) console.log(`${v.name} (${v.transport})\n console errors: ${JSON.stringify(v.consoleErrors)}`);
+}
+
+if (OUT) {
+ mkdirSync(dirname(OUT), { recursive: true });
+ writeFileSync(OUT, JSON.stringify(report, null, 2) + "\n");
+ console.log(`\nWrote ${OUT}`);
+}
+process.exit(0);
diff --git a/src/lib/audioAlert.ts b/src/lib/audioAlert.ts
index ffa592b3..68ed9a62 100644
--- a/src/lib/audioAlert.ts
+++ b/src/lib/audioAlert.ts
@@ -1,12 +1,54 @@
+import { trackAudioAlert } from './telemetry'
+
export type AlertPosition = {
distanceKm?: number
bearingDegrees?: number
}
+/** `blocked` means the browser kept audio suspended (autoplay policy). */
+export type AlertOutcome = 'played' | 'blocked'
+
+/** How long to wait for AudioContext.resume() before treating audio as blocked. */
+export const RESUME_TIMEOUT_MS = 1_000
+
+/** Played instead of the siren when audio is blocked (best-effort; needs a supporting device). */
+export const BLOCKED_VIBRATION_PATTERN = [500, 250, 500]
+
+// Input events the browser accepts as a "user activation" for audio.
+const ACTIVATION_EVENTS = ['pointerdown', 'pointerup', 'touchend', 'keydown'] as const
+
+/**
+ * Resolves to the promise's value, or `undefined` if it has not settled after
+ * `ms`. AudioContext.resume() stays pending forever (it neither resolves nor
+ * rejects) when the autoplay policy blocks it, so it cannot be awaited bare.
+ */
+function settleWithin(promise: Promise, ms: number): Promise {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(undefined), ms)
+ promise.then(
+ (value) => { clearTimeout(timer); resolve(value) },
+ () => { clearTimeout(timer); resolve(undefined) }
+ )
+ })
+}
+
type AudioContextConstructor = typeof AudioContext
export class EmergencyAudioAlert {
private context: AudioContext | null = null
+ private stats = { played: 0, blocked: 0 }
+
+ constructor(private readonly resumeTimeoutMs: number = RESUME_TIMEOUT_MS) {}
+
+ /** Alerts that sounded vs. were blocked by the autoplay policy (ISP-049). */
+ getStats(): { played: number; blocked: number } {
+ return { ...this.stats }
+ }
+
+ /** True once the audio context is running, i.e. a siren can sound without a gesture. */
+ get isUnlocked(): boolean {
+ return this.context?.state === 'running'
+ }
private getAudioContext(): AudioContext {
if (this.context) return this.context
@@ -17,9 +59,15 @@ export class EmergencyAudioAlert {
return this.context
}
- async play(position: AlertPosition = {}): Promise {
+ /**
+ * Sound the siren. If the browser keeps audio suspended (no user gesture yet,
+ * e.g. a dispatch arriving while the page was never touched) this does not
+ * hang: it gives up after `resumeTimeoutMs`, vibrates instead and returns
+ * `'blocked'` so the caller can show a visible alert.
+ */
+ async play(position: AlertPosition = {}): Promise {
const context = this.getAudioContext()
- if (context.state === 'suspended') await context.resume()
+ if (!(await this.ensureRunning(context))) return this.handleBlocked()
const distance = Math.max(0, position.distanceKm ?? 0)
const attenuation = Math.max(0.2, 1 / (1 + distance / 5))
@@ -49,11 +97,51 @@ export class EmergencyAudioAlert {
panner.disconnect()
gain.disconnect()
}
+
+ this.stats.played += 1
+ trackAudioAlert('played')
+ return 'played'
}
- async unlock(): Promise {
- const context = this.getAudioContext()
- if (context.state === 'suspended') await context.resume()
+ /** Resume audio from a user gesture. Resolves to whether the context is now running. */
+ async unlock(): Promise {
+ return this.ensureRunning(this.getAudioContext())
+ }
+
+ /**
+ * Unlock audio on the first user gesture anywhere on the page, so an alert
+ * that arrives later with no gesture can still sound. Stays armed until a
+ * gesture actually succeeds. Returns a function that disarms it (for effect cleanup).
+ */
+ armOnFirstGesture(target: Document | HTMLElement = document): () => void {
+ const disarm = () => {
+ for (const type of ACTIVATION_EVENTS) target.removeEventListener(type, onGesture)
+ }
+ const onGesture = () => {
+ this.unlock().then((running) => { if (running) disarm() }, () => undefined)
+ }
+ for (const type of ACTIVATION_EVENTS) target.addEventListener(type, onGesture)
+ return disarm
+ }
+
+ private async ensureRunning(context: AudioContext): Promise {
+ if (context.state === 'running') return true
+ await settleWithin(context.resume(), this.resumeTimeoutMs)
+ return context.state === 'running'
+ }
+
+ private handleBlocked(): AlertOutcome {
+ this.stats.blocked += 1
+ let vibrated = false
+ try {
+ vibrated = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'
+ ? navigator.vibrate(BLOCKED_VIBRATION_PATTERN)
+ : false
+ } catch {
+ // Vibration is a best-effort fallback only.
+ }
+ trackAudioAlert('blocked', { fallback: vibrated ? 'vibrate' : 'none' })
+ return 'blocked'
}
async close(): Promise {
diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts
index 542e93f6..b9e5803a 100644
--- a/src/lib/telemetry.ts
+++ b/src/lib/telemetry.ts
@@ -84,6 +84,32 @@ export function trackSubscriptionTransport(
}
}
+/**
+ * Track whether an emergency audio alert was actually audible (ISP-049).
+ * `blocked` means the browser's autoplay policy kept the AudioContext
+ * suspended, so the siren could not sound; `fallback` records what was done
+ * instead. blocked / (played + blocked) is the "audio-blocked rate".
+ */
+export function trackAudioAlert(
+ outcome: 'played' | 'blocked',
+ details: { fallback?: 'vibrate' | 'none' } = {}
+): void {
+ const span = tracer.startSpan('audio.alert')
+
+ try {
+ span.setAttributes({
+ 'audio.outcome': outcome,
+ 'audio.fallback': details.fallback ?? 'none',
+ })
+
+ if (outcome === 'blocked') {
+ span.setStatus({ code: SpanStatusCode.ERROR, message: 'autoplay blocked' })
+ }
+ } finally {
+ span.end()
+ }
+}
+
/**
* Track video performance metrics (Issue #745)
*/
diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx
index be179750..43b57a64 100644
--- a/src/pages/Help.tsx
+++ b/src/pages/Help.tsx
@@ -168,10 +168,18 @@ export default function Help() {
JSON.stringify({ contact, medicalNotes, nickname: '' })
).length
const overBudget = plaintextBytes > MAX_PAYLOAD_PLAINTEXT_BYTES
+ // ISP-049: the first tap anywhere unlocks audio, so a later alert that has no
+ // gesture of its own can sound instead of being silently blocked.
+ useEffect(() => emergencyAudioAlert.armOnFirstGesture(), [])
+
const handleTestSiren = async () => {
try {
- await emergencyAudioAlert.play()
- setStatusMessage('Emergency siren preview played.')
+ const outcome = await emergencyAudioAlert.play()
+ setStatusMessage(
+ outcome === 'played'
+ ? 'Emergency siren preview played.'
+ : 'Your browser is blocking sound. Tap the page once, then try again; vibration was used instead.'
+ )
} catch (err: any) {
setStatusMessage(err.message || 'Unable to play the emergency siren.')
}
diff --git a/test/audio-alert.test.js b/test/audio-alert.test.js
index e8f8ce86..d251e590 100644
--- a/test/audio-alert.test.js
+++ b/test/audio-alert.test.js
@@ -1,5 +1,13 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { EmergencyAudioAlert } from '../src/lib/audioAlert.ts'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const trackAudioAlert = vi.fn()
+vi.mock('../src/lib/telemetry.ts', () => ({ trackAudioAlert: (...a) => trackAudioAlert(...a) }))
+
+import {
+ BLOCKED_VIBRATION_PATTERN,
+ EmergencyAudioAlert,
+ RESUME_TIMEOUT_MS,
+} from '../src/lib/audioAlert.ts'
function makeAudioContext() {
const param = { setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn() }
@@ -14,6 +22,12 @@ function makeAudioContext() {
}
}
+// vitest 4 mocks must be `function`s to be callable with `new`; an arrow
+// function (as this file used before) throws "is not a constructor".
+function installable(context) {
+ return vi.fn(function AudioContext() { return context })
+}
+
describe('EmergencyAudioAlert', () => {
const previousAudioContext = window.AudioContext
afterEach(() => {
@@ -23,7 +37,7 @@ describe('EmergencyAudioAlert', () => {
it('synthesizes an alert and pans according to bearing', async () => {
const context = makeAudioContext()
- window.AudioContext = vi.fn(() => context)
+ window.AudioContext = installable(context)
const alert = new EmergencyAudioAlert()
await alert.play({ distanceKm: 5, bearingDegrees: 90 })
@@ -36,11 +50,131 @@ describe('EmergencyAudioAlert', () => {
it('resumes a suspended context from the user gesture path', async () => {
const context = makeAudioContext()
context.state = 'suspended'
- window.AudioContext = vi.fn(() => context)
+ context.resume = vi.fn(async () => { context.state = 'running' })
+ window.AudioContext = installable(context)
const alert = new EmergencyAudioAlert()
- await alert.unlock()
+ await expect(alert.unlock()).resolves.toBe(true)
expect(context.resume).toHaveBeenCalledOnce()
+ expect(alert.isUnlocked).toBe(true)
+ })
+})
+
+describe('EmergencyAudioAlert autoplay-blocked handling (ISP-049)', () => {
+ const previousAudioContext = window.AudioContext
+ const previousVibrate = navigator.vibrate
+ let context
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ trackAudioAlert.mockReset()
+ context = makeAudioContext()
+ context.state = 'suspended'
+ window.AudioContext = installable(context)
+ navigator.vibrate = vi.fn(() => true)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ window.AudioContext = previousAudioContext
+ navigator.vibrate = previousVibrate
+ })
+
+ it('does not hang when resume() never settles: gives up, vibrates and reports blocked', async () => {
+ context.resume = vi.fn(() => new Promise(() => {})) // what Chromium does with no gesture
+ const alert = new EmergencyAudioAlert()
+
+ const pending = alert.play()
+ await vi.advanceTimersByTimeAsync(RESUME_TIMEOUT_MS)
+
+ await expect(pending).resolves.toBe('blocked')
+ expect(context.createOscillator).not.toHaveBeenCalled()
+ expect(navigator.vibrate).toHaveBeenCalledWith(BLOCKED_VIBRATION_PATTERN)
+ expect(alert.getStats()).toEqual({ played: 0, blocked: 1 })
+ expect(trackAudioAlert).toHaveBeenCalledWith('blocked', { fallback: 'vibrate' })
+ })
+
+ it('reports blocked when resume() settles but the context stays suspended', async () => {
+ context.resume = vi.fn(async () => {})
+ const alert = new EmergencyAudioAlert()
+
+ await expect(alert.play()).resolves.toBe('blocked')
+ expect(trackAudioAlert).toHaveBeenCalledWith('blocked', { fallback: 'vibrate' })
+ })
+
+ it('reports blocked when resume() rejects', async () => {
+ context.resume = vi.fn(async () => { throw new Error('NotAllowedError') })
+ const alert = new EmergencyAudioAlert()
+
+ await expect(alert.play()).resolves.toBe('blocked')
+ })
+
+ it('records fallback "none" on devices without vibration', async () => {
+ context.resume = vi.fn(async () => {})
+ navigator.vibrate = undefined
+ const alert = new EmergencyAudioAlert()
+
+ await alert.play()
+
+ expect(trackAudioAlert).toHaveBeenCalledWith('blocked', { fallback: 'none' })
+ })
+
+ it('plays normally, without vibrating, when resume() succeeds', async () => {
+ context.resume = vi.fn(async () => { context.state = 'running' })
+ const alert = new EmergencyAudioAlert()
+
+ await expect(alert.play()).resolves.toBe('played')
+ expect(context.createOscillator).toHaveBeenCalledOnce()
+ expect(navigator.vibrate).not.toHaveBeenCalled()
+ expect(alert.getStats()).toEqual({ played: 1, blocked: 0 })
+ expect(trackAudioAlert).toHaveBeenCalledWith('played')
+ })
+
+ it('a gesture unlocks audio so a later alert needs no resume', async () => {
+ context.resume = vi.fn(async () => { context.state = 'running' })
+ const alert = new EmergencyAudioAlert()
+ const disarm = alert.armOnFirstGesture(document)
+
+ document.dispatchEvent(new Event('pointerdown'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(alert.isUnlocked).toBe(true)
+
+ document.dispatchEvent(new Event('keydown'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(context.resume).toHaveBeenCalledOnce() // listeners were removed after success
+
+ context.resume.mockClear()
+ await expect(alert.play()).resolves.toBe('played')
+ expect(context.resume).not.toHaveBeenCalled()
+ disarm()
+ })
+
+ it('stays armed until a gesture succeeds', async () => {
+ let allow = false
+ context.resume = vi.fn(async () => { if (allow) context.state = 'running' })
+ const alert = new EmergencyAudioAlert()
+ alert.armOnFirstGesture(document)
+
+ document.dispatchEvent(new Event('pointerdown'))
+ await vi.advanceTimersByTimeAsync(RESUME_TIMEOUT_MS)
+ expect(alert.isUnlocked).toBe(false)
+
+ allow = true
+ document.dispatchEvent(new Event('pointerup'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(alert.isUnlocked).toBe(true)
+ })
+
+ it('the returned disposer removes the listeners', async () => {
+ context.resume = vi.fn(async () => { context.state = 'running' })
+ const alert = new EmergencyAudioAlert()
+ const disarm = alert.armOnFirstGesture(document)
+
+ disarm()
+ document.dispatchEvent(new Event('pointerdown'))
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(context.resume).not.toHaveBeenCalled()
})
})