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')}

)} - {denied && ( + {micFailure && (
-

{t('seance.evp.deniedTitle')}

-

{t('seance.evp.denied')}

+

{t(`seance.evp.fail.${micFailure}Title`)}

+

{t(`seance.evp.fail.${micFailure}`)}

)}