From 79401338d59f1d589ebcb5ab57cdbd22e01c3964 Mon Sep 17 00:00:00 2001
From: Indiana
Date: Thu, 30 Jul 2026 01:02:16 +0000
Subject: [PATCH] fix: three real sensor-lifecycle and honesty bugs (verified,
not assumed)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From the audit whose verifier agents all died on session limits — so I
checked each claim myself rather than trusting it. One was WRONG and is
left alone; three were real.
REFUTED, deliberately unchanged: "the EMF support check is a false
positive, dead on iOS". The iOS gesture flow is correctly implemented
(EmfSensorListener.needsPermission/requestPermission) and the panel calls
it before start(). Nothing to fix; "fixing" it would have broken working
code.
1. Microphone never released when the panel unmounts mid-getUserMedia.
`this.stream` is only assigned after the await, so stop() during the
permission prompt found null and released nothing — then the promise
resolved, set running = true, and the mic went live *after* teardown,
staying on for the page's life with the recording indicator lit and an
orphaned rAF loop burning battery. Fixed with a generation counter that
makes the await cancellable. Proven: the new test fails without the
guard and passes with it (verified by reverting it).
2. Same bug class in the RTL-SDR panel: sdrRef.current is assigned after
requestDevice()+open(), so unmounting during the device picker left the
dongle claimed AND started a sweep against a dead component — only a
tab close would free it. Added a mountedRef check, mirroring the guard
EmfPanel already had.
3. EVP blamed the seeker for refusals that never happened. A bare
`catch {}` set "you refused the microphone" for every failure, so an
insecure http:// origin, a machine with no mic, and a mic held by
another app all told the user to go fix a permission that was never
denied. Now classified from the DOMException name into four honest
causes (denied / insecure / absent / busy), each with its own copy and
a working alternative, in both languages.
375 frontend tests pass; i18n parity gate passes.
Co-Authored-By: Claude Opus 5
---
frontend/src/i18n/coverage-check.mjs | 2 +
frontend/src/i18n/en.json | 14 +++-
frontend/src/i18n/es.json | 14 +++-
frontend/src/lib/evp.test.ts | 104 ++++++++++++++++++++++++++-
frontend/src/lib/evp.ts | 24 +++++++
frontend/src/pages/SeancePage.tsx | 50 +++++++++++--
6 files changed, 194 insertions(+), 14 deletions(-)
diff --git a/frontend/src/i18n/coverage-check.mjs b/frontend/src/i18n/coverage-check.mjs
index 7a0bd6c..653ae91 100644
--- a/frontend/src/i18n/coverage-check.mjs
+++ b/frontend/src/i18n/coverage-check.mjs
@@ -41,6 +41,8 @@ const RULES = [
]],
['seance.conditions.veil.', ['thin', 'veiled', 'heavy']],
['seance.views.', ['board', 'matrix', 'graphs']],
+ // Base causes only — the checker derives the matching *Title keys itself.
+ ['seance.evp.fail.', ['denied', 'insecure', 'absent', 'busy']],
['seance.entity.rarity.', RARITY],
['codex.rarity.', RARITY],
['codex.sort.', ['recent', 'contacted']],
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index e71dcd6..a829006 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -192,11 +192,19 @@
"start": "open the microphone",
"stop": "close the microphone",
"listening": "listening to the voice band…",
- "deniedTitle": "the microphone stays shut",
- "denied": "This vessel refused the microphone. Grant audio permission in your browser's site settings to practice EVP — or turn to the ouija board or the Wire Ghost instead; the board needs no ear, and the wire only the network you already fear.",
"floor": "room floor",
"band": "voice band 300–3400 Hz",
- "scopeLabel": "live audio spectrum from the microphone"
+ "scopeLabel": "live audio spectrum from the microphone",
+ "fail": {
+ "deniedTitle": "the microphone stays shut",
+ "denied": "This vessel refused the microphone. Grant audio permission in your browser's site settings to practice EVP — or turn to the ouija board or the Wire Ghost instead; the board needs no ear, and the wire only the network you already fear.",
+ "insecureTitle": "unconsecrated ground",
+ "insecure": "The dead only speak through a secured channel. Open this site by its https address — or over localhost — and the microphone will answer. Until then, the ouija board and the Wire Ghost need no ear at all.",
+ "absentTitle": "no ear to listen with",
+ "absent": "This vessel has no microphone the veil can find. Attach one and try again — or turn to the ouija board or the Wire Ghost, which hear by other means.",
+ "busyTitle": "the ear is already spoken for",
+ "busy": "Another rite already holds the microphone. Close whatever else is listening — a call, a recorder, another tab — then open it here again."
+ }
},
"radio": {
"start": "sweep the band",
diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json
index 799a266..d3f84f7 100644
--- a/frontend/src/i18n/es.json
+++ b/frontend/src/i18n/es.json
@@ -192,11 +192,19 @@
"start": "abrir el micrófono",
"stop": "cerrar el micrófono",
"listening": "escuchando la banda de voz…",
- "deniedTitle": "el micrófono sigue cerrado",
- "denied": "Este recipiente rechazó el micrófono. Concede el permiso de audio en los ajustes del sitio de tu navegador para practicar EVP — o acude al tablero ouija o al Fantasma del Cable; el tablero no necesita oído, y el cable solo la red que ya temes.",
"floor": "suelo de la sala",
"band": "banda de voz 300–3400 Hz",
- "scopeLabel": "espectro de audio en vivo desde el micrófono"
+ "scopeLabel": "espectro de audio en vivo desde el micrófono",
+ "fail": {
+ "deniedTitle": "el micrófono permanece cerrado",
+ "denied": "Este recipiente rechazó el micrófono. Concede permiso de audio en los ajustes del navegador para practicar PVE — o acude al tablero ouija o al Fantasma del Cable; el tablero no necesita oído, y el cable solo la red que ya temes.",
+ "insecureTitle": "tierra sin consagrar",
+ "insecure": "Los muertos solo hablan por un canal seguro. Abre este sitio por su dirección https — o desde localhost — y el micrófono responderá. Hasta entonces, el tablero ouija y el Fantasma del Cable no necesitan oído alguno.",
+ "absentTitle": "ningún oído con que escuchar",
+ "absent": "Este recipiente no tiene micrófono que el velo pueda hallar. Conecta uno e inténtalo de nuevo — o acude al tablero ouija o al Fantasma del Cable, que oyen por otros medios.",
+ "busyTitle": "el oído ya está tomado",
+ "busy": "Otro rito ya sostiene el micrófono. Cierra lo que esté escuchando — una llamada, una grabadora, otra pestaña — y ábrelo aquí de nuevo."
+ }
},
"radio": {
"start": "barrer la banda",
diff --git a/frontend/src/lib/evp.test.ts b/frontend/src/lib/evp.test.ts
index 18d045c..c3fe3ac 100644
--- a/frontend/src/lib/evp.test.ts
+++ b/frontend/src/lib/evp.test.ts
@@ -1,8 +1,9 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EVP_BASE_THRESHOLD_DB,
EVP_LISTENING_TOOL_THRESHOLD_DB,
EvpDetectorCore,
+ EvpListener,
evpThresholdDb,
} from './evp'
import type { EvpAnomaly } from './evp'
@@ -204,3 +205,104 @@ describe('listening_tool threshold changes real detector behavior', () => {
expect(anomaly!.magnitude).toBeCloseTo(6, 6)
})
})
+
+
+// --- microphone lifecycle -------------------------------------------------
+
+describe('EvpListener microphone release', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ /** A getUserMedia we control the timing of, plus track-stop spies. */
+ function stubMedia() {
+ const stopped: string[] = []
+ const tracks = [
+ { stop: () => stopped.push('a') },
+ { stop: () => stopped.push('b') },
+ ]
+ let release: (() => void) | null = null
+ const pending = new Promise((resolve) => {
+ release = () =>
+ resolve({ getTracks: () => tracks } as unknown as MediaStream)
+ })
+ vi.stubGlobal('navigator', {
+ mediaDevices: { getUserMedia: () => pending },
+ })
+ // AudioContext should never be constructed on an abandoned start, but
+ // provide one so a regression fails on the mic assertion rather than
+ // blowing up on a missing global.
+ vi.stubGlobal(
+ 'AudioContext',
+ class {
+ sampleRate = 48000
+ state = 'running'
+ createMediaStreamSource() {
+ return { connect: () => undefined }
+ }
+ createAnalyser() {
+ return {
+ fftSize: 2048,
+ smoothingTimeConstant: 0,
+ frequencyBinCount: 1024,
+ getFloatFrequencyData: () => undefined,
+ connect: () => undefined,
+ }
+ }
+ close() {
+ return Promise.resolve()
+ }
+ },
+ )
+ vi.stubGlobal('requestAnimationFrame', () => 1)
+ vi.stubGlobal('cancelAnimationFrame', () => undefined)
+ return { stopped, release: () => release!() }
+ }
+
+ it('releases the microphone when stopped while getUserMedia is still pending', async () => {
+ // The real-world path: the seeker switches mode (or leaves) while the
+ // browser is still showing the permission prompt. Before the generation
+ // guard, stop() found this.stream === null, released nothing, and the
+ // mic went live *after* teardown — staying on for the page's lifetime.
+ const { stopped, release } = stubMedia()
+ const listener = new EvpListener()
+
+ const starting = listener.start({ onAnomaly: () => undefined })
+ await listener.stop() // unmount lands mid-prompt
+ release() // permission finally granted
+ await starting
+
+ expect(stopped).toHaveLength(2)
+ expect(listener.isRunning).toBe(false)
+ })
+
+ it('does not start the render loop for an abandoned start', async () => {
+ const { release } = stubMedia()
+ const listener = new EvpListener()
+ let frames = 0
+ const starting = listener.start({
+ onAnomaly: () => undefined,
+ onFrame: () => {
+ frames++
+ },
+ })
+ await listener.stop()
+ release()
+ await starting
+ expect(frames).toBe(0)
+ })
+
+ it('still runs normally when nobody interrupts it', async () => {
+ const { stopped, release } = stubMedia()
+ const listener = new EvpListener()
+ const starting = listener.start({ onAnomaly: () => undefined })
+ release()
+ await starting
+
+ expect(listener.isRunning).toBe(true)
+ expect(stopped).toHaveLength(0)
+
+ await listener.stop()
+ expect(stopped).toHaveLength(2)
+ })
+})
diff --git a/frontend/src/lib/evp.ts b/frontend/src/lib/evp.ts
index 132a8c0..5eaeaa9 100644
--- a/frontend/src/lib/evp.ts
+++ b/frontend/src/lib/evp.ts
@@ -173,6 +173,9 @@ export class EvpListener {
private raf = 0
private core: EvpDetectorCore | null = null
private running = false
+ /** Bumped by every start() and every stop(); lets a start() suspended on
+ * getUserMedia detect that it was abandoned. See start(). */
+ private generation = 0
get isRunning(): boolean {
return this.running
@@ -196,7 +199,25 @@ export class EvpListener {
async start(cb: EvpListenerCallbacks, opts: EvpListenerStartOptions = {}): Promise {
if (this.running) return
+
+ // getUserMedia can take seconds (the browser may be showing a
+ // permission prompt), and the panel can unmount in that window — mode
+ // switch, leaving the séance, a remount. stop() called during the await
+ // could not release anything, because `this.stream` is only assigned
+ // once the promise resolves: the mic would go live *after* teardown and
+ // stay live for the rest of the page's life, with the browser's
+ // recording indicator on and an orphaned rAF loop still running.
+ //
+ // A generation counter makes the await cancellable: stop() bumps it, so
+ // when the promise finally resolves we can tell that this attempt was
+ // abandoned and hand the stream straight back instead of wiring it up.
+ const generation = ++this.generation
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ if (generation !== this.generation) {
+ stream.getTracks().forEach((t) => t.stop())
+ return
+ }
+
const ctx = new AudioContext()
const src = ctx.createMediaStreamSource(stream)
const analyser = ctx.createAnalyser()
@@ -227,6 +248,9 @@ export class EvpListener {
}
async stop(): Promise {
+ // Invalidates any start() currently suspended on getUserMedia, so a
+ // late-resolving stream is released rather than going live post-teardown.
+ this.generation++
this.running = false
cancelAnimationFrame(this.raf)
this.stream?.getTracks().forEach((t) => t.stop())
diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx
index a7384df..ecb67e8 100644
--- a/frontend/src/pages/SeancePage.tsx
+++ b/frontend/src/pages/SeancePage.tsx
@@ -543,7 +543,11 @@ function EvpPanel() {
const hasListeningTool = user?.unlocks?.includes('listening_tool') ?? false
const [listening, setListening] = useState(false)
const [pending, setPending] = useState(false)
- const [denied, setDenied] = useState(false)
+ // Why the mic isn't open, so the notice can be truthful about the cause
+ // instead of always claiming the seeker refused it.
+ const [micFailure, setMicFailure] = useState<
+ 'denied' | 'insecure' | 'absent' | 'busy' | null
+ >(null)
const [bandDb, setBandDb] = useState(null)
const [nyquist, setNyquist] = useState(24000)
const [markers, setMarkers] = useState([])
@@ -569,7 +573,7 @@ function EvpPanel() {
const start = async () => {
if (pending || listening) return
- setDenied(false)
+ setMicFailure(null)
setPending(true)
const listener = listenerRef.current ?? new EvpListener()
listenerRef.current = listener
@@ -628,9 +632,26 @@ function EvpPanel() {
}
setListening(true)
}
- } catch {
+ } catch (err) {
if (listenerRef.current === listener) listenerRef.current = null
- setDenied(true)
+ // Blaming the seeker for a refusal that never happened sends them to
+ // fix the wrong thing. getUserMedia's DOMException names distinguish
+ // the real causes, so each one gets truthful, actionable copy:
+ // NotAllowedError is a genuine denial; NotFoundError means there's no
+ // microphone at all; NotReadableError means another app holds it; and
+ // on a bare http:// origin mediaDevices is simply absent.
+ const name = err instanceof DOMException ? err.name : ''
+ const noMediaApi =
+ typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
+ setMicFailure(
+ noMediaApi || name === 'SecurityError'
+ ? 'insecure'
+ : name === 'NotFoundError' || name === 'OverconstrainedError'
+ ? 'absent'
+ : name === 'NotReadableError' || name === 'AbortError'
+ ? 'busy'
+ : 'denied',
+ )
setBandDb(null)
} finally {
setPending(false)
@@ -677,10 +698,10 @@ function EvpPanel() {
{t('seance.evp.listening')}