fix: three real sensor-lifecycle and honesty bugs (verified, not assumed)

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 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-30 01:02:16 +00:00
parent b6d491d563
commit 79401338d5
6 changed files with 194 additions and 14 deletions

View File

@@ -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",