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